Repository: compiler-explorer/compiler-explorer
Pull request #7119 (2024-11-24 09:01:44)

[feat] Add LFortran as a new fortran compiler
Details:
LFortran is a modern open-source (BSD licensed) interactive Fortran
 compiler built on top of LLVM. See, https://lfortran.org for more details.
 There was an interest in the community to add the compiler into the 
 Compiler Explorer: https://github.com/lfortran/lfortran/issues/623. 
 So, this PR adds it.

LFortran is in alpha (the source code compiles to binary and execute. 
 But, users are expected that their code can break). It can emits AST, 
 ASR, LLVM, C, C++, WAT, Julia, Fortran, etc. It has multiple backend
 like LLVM (default), C, C++, x86, WASM, fortran and mlir. 
 For more options run `--help`.

Changes in this PR:
- Add LFortran as a new Fortran compiler.
- `-g` options is enabled by default in `optionsForFilter`. Providing an option 
 to disabling it helps other compilers (that doesn't have complete support for
 debug information) to compile successfully

Thirumalai-Shaktivel: ping @partouf and @AbrilRBS for review.


Thirumalai-Shaktivel: How do I include the binary to `/opt/compiler-explorer`?

I came across the repo: https://github.com/compiler-explorer/infra, but the README doesn't help me much.
LFortran is available through conda, see https://lfortran.org/download/ for more available options.
mattgodbolt: Hi @Thirumalai-Shaktivel - sorry the README isn't clear enough. We have to be able to install our binaries on a network drive that's "hermetic" - that is it doesn't require external packages. `conda` might be an option here, though in my experience getting `conda` working well can be tricky. We don't currently install anything else via conda. We'd have to add support for that kind of installation (noting that the host we "install" from is usually not the host we run things on, which _can_ be an issue with some approaches).

We very often compile our compilers from source, so that's an option too.
Thirumalai-Shaktivel: > We very often compile our compilers from source, so that's an option too.

Great, LFortran has source tarballs from Downloads page. I will check it out.
Thirumalai-Shaktivel: > why doesn't lfortran support debug? It seems very useful for compiler explorer: that's kind of why we force debug symbols on :) CE isn't much use without it

That makes sense.

There was some problem the llvm-dwarfdump. I didn't had much time to debug it. Now, I will try to fix and ping you if I face any issue.

Thirumalai-Shaktivel: > We very often compile our compilers from source, so that's an option too.

I went through the `infra` repo and in `*.yaml` file, I saw some compiler using `type: script`.
So, I assume that is responsible for compiling the compilers from source. 

LFortran building from source requires conda environment, do we have that in `infra`?
Here is the environment list that we use in LFortran CI: https://github.com/lfortran/lfortran/blob/main/ci/environment_linux.yml (Note, some packages like LLVM, re2c, bison, ninja and cmake is mandatory). Do let me know what needs to be done to get them for the source build.
mattgodbolt: >>> I went through the infra repo and in *.yaml file, I saw some compiler using type: script.
>>> So, I assume that is responsible for compiling the compilers from source.

Unfortunately no: the installation host is not the same as the running host(s) so we can't build things there.

We have a number of "builder" images. I'd be happy to look into building fortran for you though - I'd add it in https://github.com/compiler-explorer/misc-builder/


Patch:
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 7657d4aea..6ea83bdd5 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -149,6 +149,7 @@
       - any-glob-to-any-file:
           - 'lib/compilers/flang.ts'
           - 'lib/compilers/flang-fc1.ts'
+          - 'lib/compilers/lfortran.ts'
           - 'lib/compilers/fortran.ts'
           - 'etc/config/fortran.*.properties'
           - 'static/modes/fortran-mode.ts'
diff --git a/app.ts b/app.ts
index d466b6a17..12c70e5df 100755
--- a/app.ts
+++ b/app.ts
@@ -57,9 +57,11 @@ import {startWineInit} from './lib/exec.js';
 import {RemoteExecutionQuery} from './lib/execution/execution-query.js';
 import {initHostSpecialties} from './lib/execution/execution-triple.js';
 import {startExecutionWorkerThread} from './lib/execution/sqs-execution-queue.js';
+import {AssemblyDocumentationController} from './lib/handlers/api/assembly-documentation-controller.js';
+import {HealthcheckController} from './lib/handlers/api/healthcheck-controller.js';
+import {SiteTemplateController} from './lib/handlers/api/site-template-controller.js';
 import {SourceController} from './lib/handlers/api/source-controller.js';
 import {CompileHandler} from './lib/handlers/compile.js';
-import * as healthCheck from './lib/handlers/health-check.js';
 import {NoScriptHandler} from './lib/handlers/noscript.js';
 import {RouteAPI, ShortLinkMetaData} from './lib/handlers/route-api.js';
 import {languages as allLanguages} from './lib/languages.js';
@@ -554,9 +556,21 @@ async function main() {
     const compileHandler = new CompileHandler(compilationEnvironment, awsProps);
     const storageType = getStorageTypeByKey(storageSolution);
     const storageHandler = new storageType(httpRoot, compilerProps, awsProps);
-    const sourceHandler = new SourceController(sources);
     const compilerFinder = new CompilerFinder(compileHandler, compilerProps, awsProps, defArgs, clientOptionsHandler);
 
+    const isExecutionWorker = ceProps<boolean>('execqueue.is_worker', false);
+    const healthCheckFilePath = ceProps('healthCheckFilePath', null) as string | null;
+
+    const siteTemplateController = new SiteTemplateController();
+    const sourceController = new SourceController(sources);
+    const assemblyDocumentationController = new AssemblyDocumentationController();
+    const healthCheckController = new HealthcheckController(
+        compilationQueue,
+        healthCheckFilePath,
+        compileHandler,
+        isExecutionWorker,
+    );
+
     logger.info('=======================================');
     if (gitReleaseName) logger.info(`  git release ${gitReleaseName}`);
     if (releaseBuildNumber) logger.info(`  release build ${releaseBuildNumber}`);
@@ -564,8 +578,6 @@ async function main() {
     let initialCompilers: CompilerInfo[];
     let prevCompilers: CompilerInfo[];
 
-    const isExecutionWorker = ceProps<boolean>('execqueue.is_worker', false);
-
     if (opts.prediscovered) {
         const prediscoveredCompilersJson = await fs.readFile(opts.prediscovered, 'utf8');
         initialCompilers = JSON.parse(prediscoveredCompilersJson);
@@ -606,8 +618,6 @@ async function main() {
         process.exit(0);
     }
 
-    const healthCheckFilePath = ceProps('healthCheckFilePath', false);
-
     // Exported to allow compilers to refer to other existing compilers.
     global.handler_config = {
         compileHandler,
@@ -678,12 +688,6 @@ async function main() {
                 }
             }),
         )
-        // Handle healthchecks at the root, as they're not expected from the outside world
-        .use(
-            '/healthcheck',
-            new healthCheck.HealthCheckHandler(compilationQueue, healthCheckFilePath, compileHandler, isExecutionWorker)
-                .handle,
-        )
         .use(httpRoot, router)
         .use((req, res, next) => {
             next({status: 404, message: `page "${req.path}" could not be found`});
@@ -867,8 +871,10 @@ async function main() {
             );
         })
         .use(bodyParser.json({limit: ceProps('bodyParserLimit', maxUploadSize)}))
-        .use('/source/:source/list', sourceHandler.listEntries.bind(sourceHandler))
-        .use('/source/:source/load/:language/:filename', sourceHandler.loadEntry.bind(sourceHandler))
+        .use(siteTemplateController.createRouter())
+        .use(sourceController.createRouter())
+        .use(assemblyDocumentationController.createRouter())
+        .use(healthCheckController.createRouter())
         .get('/g/:id', oldGoogleUrlHandler)
         // Deprecated old route for this -- TODO remove in late 2021
         .post('/shortener', routeApi.apiHandler.shortener.handle.bind(routeApi.apiHandler.shortener));
diff --git a/etc/config/fortran.amazon.properties b/etc/config/fortran.amazon.properties
index 66f70fed9..fff0e6273 100644
--- a/etc/config/fortran.amazon.properties
+++ b/etc/config/fortran.amazon.properties
@@ -1,4 +1,4 @@
-compilers=&gfortran_86:&ifort:&ifx:&nvfortran_x86:&cross:&clang_llvmflang
+compilers=&gfortran_86:&ifort:&ifx:&nvfortran_x86:&cross:&clang_llvmflang:&lfortran
 defaultCompiler=gfortran142
 demangler=/opt/compiler-explorer/gcc-14.2.0/bin/c++filt
 objdumper=/opt/compiler-explorer/gcc-14.2.0/bin/objdump
@@ -735,6 +735,18 @@ compiler.flangtrunk-fc.supportsBinaryObject=false
 compiler.flangtrunk-fc.supportsBinaryExecute=false
 compiler.flangtrunk-fc.alias=flangtrunknew-fc
 
+#################################
+# LFortran
+group.lfortran.compilers=lfortran
+group.lfortran.groupName=LFortran
+
+compiler.lfortran.name=lfortran (latest)
+compiler.lfortran.exe=/opt/compiler-explorer/lfortran/bin/lfortran
+compiler.lfortran.options=--generate-object-code
+compiler.lfortran.supportsBinary=true
+compiler.lfortran.supportsExecute=true
+compiler.lfortran.compilerType=lfortran
+
 ###############################
 # GCC for ARM 64bit
 group.gccaarch64.compilers=farm64g494:farm64g550:farm64g640:farm64g730:farm64g820:farm64g1050:farm64g1210:farm64g1220:farm64g1230:farm64g1310:farm64g1140:farm64g1320:farm64g1410:farm64g1330:farm64g1240:farm64g1420
diff --git a/etc/config/java.amazon.properties b/etc/config/java.amazon.properties
index e3cafe607..0bb2fb1ec 100644
--- a/etc/config/java.amazon.properties
+++ b/etc/config/java.amazon.properties
@@ -1,7 +1,7 @@
 compilers=&java8:&java9:&java10:&java11:&java12:&java13:&java14:&java15:&java16:&java17:&java18:&java19:&java20:&java21:&java22:&java23
 compilerType=java
 versionFlag=-version
-defaultCompiler=java2301
+defaultCompiler=java2102
 demangler=
 postProcess=
 options=
diff --git a/etc/config/kotlin.amazon.properties b/etc/config/kotlin.amazon.properties
index 96134cafd..9c245b057 100644
--- a/etc/config/kotlin.amazon.properties
+++ b/etc/config/kotlin.amazon.properties
@@ -1,9 +1,9 @@
 compilers=&kotlin
 compilerType=kotlin
 versionFlag=-version
-objdumper=/opt/compiler-explorer/jdk-21.0.0/bin/javap
+objdumper=/opt/compiler-explorer/jdk-23.0.1/bin/javap
 instructionSet=java
-defaultCompiler=kotlinc2000
+defaultCompiler=kotlinc2021
 demangler=
 postProcess=
 options=
@@ -12,7 +12,7 @@ needsMulti=false
 supportsExecute=true
 interpreted=true
 
-group.kotlin.compilers=kotlinc1400:kotlinc1410:kotlinc1420:kotlinc1421:kotlinc1430:kotlinc1431:kotlinc1432:kotlinc1500:kotlinc1510:kotlinc1520:kotlinc1521:kotlinc1530:kotlinc1531:kotlinc1600:kotlinc1610:kotlinc1620:kotlinc1700:kotlinc1800:kotlinc1810:kotlinc1820:kotlinc1900:kotlinc1910:kotlinc1920:kotlinc2000
+group.kotlin.compilers=kotlinc1400:kotlinc1410:kotlinc1420:kotlinc1421:kotlinc1430:kotlinc1431:kotlinc1432:kotlinc1500:kotlinc1510:kotlinc1520:kotlinc1521:kotlinc1530:kotlinc1531:kotlinc1600:kotlinc1610:kotlinc1620:kotlinc1700:kotlinc1800:kotlinc1810:kotlinc1820:kotlinc1900:kotlinc1910:kotlinc1920:kotlinc2000:kotlinc2010:kotlinc2020:kotlinc2021
 group.kotlin.groupName=Kotlin
 group.kotlin.baseName=kotlinc
 group.kotlin.isSemVer=true
@@ -115,3 +115,15 @@ compiler.kotlinc2000.exe=/opt/compiler-explorer/kotlin-jvm-2.0.0/bin/kotlinc-jvm
 compiler.kotlinc2000.semver=2.0.0
 compiler.kotlinc2000.java_home=/opt/compiler-explorer/jdk-21.0.0
 compiler.kotlinc2000.runtime=/opt/compiler-explorer/jdk-21.0.0/bin/java
+compiler.kotlinc2010.exe=/opt/compiler-explorer/kotlin-jvm-2.0.10/bin/kotlinc-jvm
+compiler.kotlinc2010.semver=2.0.10
+compiler.kotlinc2010.java_home=/opt/compiler-explorer/jdk-23.0.1
+compiler.kotlinc2010.runtime=/opt/compiler-explorer/jdk-23.0.1/bin/java
+compiler.kotlinc2020.exe=/opt/compiler-explorer/kotlin-jvm-2.0.20/bin/kotlinc-jvm
+compiler.kotlinc2020.semver=2.0.20
+compiler.kotlinc2020.java_home=/opt/compiler-explorer/jdk-23.0.1
+compiler.kotlinc2020.runtime=/opt/compiler-explorer/jdk-23.0.1/bin/java
+compiler.kotlinc2021.exe=/opt/compiler-explorer/kotlin-jvm-2.0.21/bin/kotlinc-jvm
+compiler.kotlinc2021.semver=2.0.21
+compiler.kotlinc2021.java_home=/opt/compiler-explorer/jdk-23.0.1
+compiler.kotlinc2021.runtime=/opt/compiler-explorer/jdk-23.0.1/bin/java
diff --git a/lib/base-compiler.ts b/lib/base-compiler.ts
index 830c9f486..1d492d84e 100644
--- a/lib/base-compiler.ts
+++ b/lib/base-compiler.ts
@@ -180,6 +180,10 @@ export interface SimpleOutputFilenameCompiler {
     getOutputFilename(dirPath: string): string;
 }
 
+function isOutputLikelyLllvmIr(compilerOptions) {
+    return compilerOptions && (compilerOptions.includes('-emit-llvm') || compilerOptions.includes('-mlir-to-llvmir'));
+}
+
 export class BaseCompiler {
     public compiler: CompilerInfo;
     public lang: Language;
@@ -3063,7 +3067,7 @@ export class BaseCompiler {
             if (this.compiler.supportsCfg && backendOptions.produceCfg && backendOptions.produceCfg.asm) {
                 const isLlvmIr =
                     this.compiler.instructionSet === 'llvm' ||
-                    (options && options.includes('-emit-llvm')) ||
+                    (options && isOutputLikelyLllvmIr(options)) ||
                     this.llvmIr.isLlvmIr(result.asm);
                 result.cfg = cfg.generateStructure(this.compiler, result.asm, isLlvmIr);
             }
@@ -3114,7 +3118,7 @@ export class BaseCompiler {
     }
 
     async processAsm(result, filters: ParseFiltersAndOutputOptions, options: string[]) {
-        if ((options && options.includes('-emit-llvm')) || this.llvmIr.isLlvmIr(result.asm)) {
+        if ((options && isOutputLikelyLllvmIr(options)) || this.llvmIr.isLlvmIr(result.asm)) {
             return await this.llvmIr.processFromFilters(result.asm, filters);
         }
 
diff --git a/lib/compilers/_all.ts b/lib/compilers/_all.ts
index 2a9a63528..ba167e568 100644
--- a/lib/compilers/_all.ts
+++ b/lib/compilers/_all.ts
@@ -72,6 +72,7 @@ export {EWAVRCompiler} from './ewavr.js';
 export {FakeCompiler} from './fake-for-test.js';
 export {FlangCompiler} from './flang.js';
 export {FlangFC1Compiler} from './flang-fc1.js';
+export {LFortranCompiler} from './lfortran.js';
 export {FortranCompiler} from './fortran.js';
 export {FPCCompiler} from './pascal.js';
 export {GCCCompiler} from './gcc.js';
diff --git a/lib/compilers/dotnet.ts b/lib/compilers/dotnet.ts
index 9b48d9a12..226bf565b 100644
--- a/lib/compilers/dotnet.ts
+++ b/lib/compilers/dotnet.ts
@@ -173,6 +173,7 @@ class DotNetCompiler extends BaseCompiler {
             '--noscan',
             '--noinlinetls',
             '--completetypemetadata',
+            '--help',
             '-bytes',
             '-raweh',
             '-tokens',
diff --git a/lib/compilers/lfortran.ts b/lib/compilers/lfortran.ts
new file mode 100644
index 000000000..497282c30
--- /dev/null
+++ b/lib/compilers/lfortran.ts
@@ -0,0 +1,45 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import type {ParseFiltersAndOutputOptions} from '../../types/features/filters.interfaces.js';
+
+import {FortranCompiler} from './fortran.js';
+
+export class LFortranCompiler extends FortranCompiler {
+    static override get key() {
+        return 'lfortran';
+    }
+
+    override optionsForFilter(filters: ParseFiltersAndOutputOptions, outputFilename: string) {
+        // TODO add `-g` to the options
+        let options = ['-o', this.filename(outputFilename)];
+
+        if (!filters.binary && !filters.binaryObject) {
+            options = options.concat('-S');
+        } else if (filters.binaryObject) {
+            options = options.concat('-c');
+        }
+        return options;
+    }
+}
diff --git a/lib/compilers/mlir.ts b/lib/compilers/mlir.ts
index 1e20aa737..20936def8 100644
--- a/lib/compilers/mlir.ts
+++ b/lib/compilers/mlir.ts
@@ -31,6 +31,10 @@ import {CompilationEnvironment} from '../compilation-env.js';
 
 import {BaseParser} from './argument-parsers.js';
 
+function isMlirTranslate(compilerInfo: PreliminaryCompilerInfo): boolean {
+    return compilerInfo.group === 'mlirtranslate';
+}
+
 export class MLIRCompiler extends BaseCompiler {
     static get key() {
         return 'mlir';
@@ -39,18 +43,20 @@ export class MLIRCompiler extends BaseCompiler {
     constructor(compilerInfo: PreliminaryCompilerInfo, env: CompilationEnvironment) {
         super(
             {
-                disabledFilters: [
-                    'binary',
-                    'execute',
-                    'demangle',
-                    'intel',
-                    'labels',
-                    'libraryCode',
-                    'directives',
-                    'commentOnly',
-                    'trim',
-                    'debugCalls',
-                ],
+                disabledFilters: isMlirTranslate(compilerInfo)
+                    ? []
+                    : [
+                          'binary',
+                          'execute',
+                          'demangle',
+                          'intel',
+                          'labels',
+                          'libraryCode',
+                          'directives',
+                          'commentOnly',
+                          'trim',
+                          'debugCalls',
+                      ],
                 ...compilerInfo,
             },
             env,
@@ -85,20 +91,22 @@ export class MLIRCompiler extends BaseCompiler {
         // at some point maybe a custom parser can be written, for now just don't filter anything
         return super.processAsm(
             result,
-            {
-                labels: false,
-                binary: false,
-                commentOnly: false,
-                demangle: false,
-                optOutput: false,
-                directives: false,
-                dontMaskFilenames: false,
-                execute: false,
-                intel: false,
-                libraryCode: false,
-                trim: false,
-                debugCalls: false,
-            },
+            isMlirTranslate(this.compiler)
+                ? filters
+                : {
+                      labels: false,
+                      binary: false,
+                      commentOnly: false,
+                      demangle: false,
+                      optOutput: false,
+                      directives: false,
+                      dontMaskFilenames: false,
+                      execute: false,
+                      intel: false,
+                      libraryCode: false,
+                      trim: false,
+                      debugCalls: false,
+                  },
             options,
         );
     }
diff --git a/lib/handlers/api.ts b/lib/handlers/api.ts
index a90b3a228..05880f63d 100644
--- a/lib/handlers/api.ts
+++ b/lib/handlers/api.ts
@@ -41,10 +41,8 @@ import {SentryCapture} from '../sentry.js';
 import {BaseShortener, getShortenerTypeByKey} from '../shortener/index.js';
 import {StorageBase} from '../storage/index.js';
 
-import {withAssemblyDocumentationProviders} from './assembly-documentation.js';
 import {CompileHandler} from './compile.js';
 import {FormattingHandler} from './formatting.js';
-import {getSiteTemplates} from './site-templates.js';
 
 function methodNotAllowed(req: express.Request, res: express.Response) {
     res.send('Method Not Allowed');
@@ -92,9 +90,6 @@ export class ApiHandler {
 
         this.handle.route('/libraries').get(this.handleAllLibraries.bind(this)).all(methodNotAllowed);
 
-        // Binding for assembly documentation
-        withAssemblyDocumentationProviders(this.handle);
-        // Legacy binding for old clients.
         this.handle
             .route('/asm/:opcode')
             .get((req, res) => res.redirect(`amd64/${req.params.opcode}`))
@@ -139,13 +134,6 @@ export class ApiHandler {
                 res.send(all);
             })
             .all(methodNotAllowed);
-        this.handle
-            .route('/siteTemplates')
-            .get(async (req, res) => {
-                res.send(await getSiteTemplates());
-            })
-            .all(methodNotAllowed);
-
         this.handle.route('/shortlinkinfo/:id').get(this.shortlinkInfoHandler.bind(this)).all(methodNotAllowed);
 
         const shortenerType = getShortenerTypeByKey(urlShortenService);
diff --git a/lib/handlers/api/assembly-documentation-controller.ts b/lib/handlers/api/assembly-documentation-controller.ts
new file mode 100644
index 000000000..9893105f4
--- /dev/null
+++ b/lib/handlers/api/assembly-documentation-controller.ts
@@ -0,0 +1,75 @@
+// Copyright (c) 2021, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import express from 'express';
+
+import {addStaticHeaders} from '../../../app.js';
+import {getDocumentationProviderTypeByKey} from '../../asm-docs/index.js';
+
+import {HttpController} from './controller.interfaces.js';
+
+export class AssemblyDocumentationController implements HttpController {
+    createRouter(): express.Router {
+        const router = express.Router();
+        router.get('/api/asm/:arch/:opcode', this.getOpcodeDocumentation.bind(this));
+        return router;
+    }
+
+    /**
+     * Handle request to `/asm/:arch/:opcode` endpoint
+     */
+    public async getOpcodeDocumentation(req: express.Request, res: express.Response) {
+        try {
+            const Provider = getDocumentationProviderTypeByKey(req.params.arch);
+            const provider = new Provider();
+            // If there is no opcode, we should just fail with 404 anyways... Assumes that no assembly language has
+            // a __unknown_opcode instruction.
+            const instruction = (req.params.opcode || '__UNKNOWN_OPCODE').toUpperCase();
+            const information = provider.getInstructionInformation(instruction);
+            if (information === null) {
+                return res.status(404).send({error: `Unknown opcode '${instruction}'`});
+            }
+
+            const contentType = req.accepts(['text', 'json']);
+            switch (contentType) {
+                case 'text': {
+                    addStaticHeaders(res);
+                    res.send(information.html);
+                    break;
+                }
+                case 'json': {
+                    addStaticHeaders(res);
+                    res.send(information);
+                    break;
+                }
+                default: {
+                    res.status(406).send({error: 'Not Acceptable'});
+                    break;
+                }
+            }
+        } catch {
+            return res.status(404).send({error: `No documentation for '${req.params.arch}'`});
+        }
+    }
+}
diff --git a/lib/handlers/api/controller.interfaces.ts b/lib/handlers/api/controller.interfaces.ts
new file mode 100644
index 000000000..52ab0e28f
--- /dev/null
+++ b/lib/handlers/api/controller.interfaces.ts
@@ -0,0 +1,35 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import express from 'express';
+
+export interface HttpController {
+    /**
+     * Create a separate express router for this controller.
+     *
+     * The purpose of this is to allow for a consistent API surface across both test and production, as well as being
+     * able to create multiple mergable routes for the final server.
+     */
+    createRouter(): express.Router;
+}
diff --git a/lib/handlers/api/healthcheck-controller.ts b/lib/handlers/api/healthcheck-controller.ts
new file mode 100644
index 000000000..b45e642cb
--- /dev/null
+++ b/lib/handlers/api/healthcheck-controller.ts
@@ -0,0 +1,87 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import fs from 'node:fs/promises';
+
+import express from 'express';
+
+import {CompilationQueue} from '../../compilation-queue.js';
+import {logger} from '../../logger.js';
+import {SentryCapture} from '../../sentry.js';
+import {ICompileHandler} from '../compile.interfaces.js';
+
+import {HttpController} from './controller.interfaces.js';
+
+export class HealthcheckController implements HttpController {
+    public constructor(
+        private readonly compilationQueue: CompilationQueue,
+        private readonly healthCheckFilePath: string | null,
+        private readonly compileHandler: ICompileHandler,
+        private readonly isExecutionWorker: boolean,
+    ) {}
+
+    createRouter(): express.Router {
+        const router = express.Router();
+        // TODO: Consider if this could be `GET` only. It doesn't quite make sense to do anything but GET the
+        //  healthcheck
+        router.use('/healthcheck', this.healthcheck.bind(this));
+        return router;
+    }
+
+    /**
+     * Handle request to `/healthcheck` endpoint
+     * @param req
+     * @param res
+     */
+    public async healthcheck(req: express.Request, res: express.Response) {
+        // Enqueue an empty job to ensure the queue is running. This check simply tests that the queue is being
+        // processed by _something_.
+        await this.compilationQueue.enqueue(async () => {}, {highPriority: true});
+
+        // If this is a worker, we don't require that the server has languages configured.
+        if (!this.isExecutionWorker && !this.compileHandler.hasLanguages()) {
+            logger.error(`*** HEALTH CHECK FAILURE: no languages/compilers detected`);
+            return res.status(500).send();
+        }
+
+        // If we have a healthcheck file, we require that it exists and it is non-empty. The /efs/.health file contents
+        // are not important, but the file acts as a health check for EFS mounts.
+        if (this.healthCheckFilePath !== null) {
+            try {
+                const content = await fs.readFile(this.healthCheckFilePath);
+                if (content.length === 0) {
+                    throw new Error('File is empty');
+                }
+                res.set('Content-Type', 'text/html');
+                return res.send(content);
+            } catch (e) {
+                logger.error(`*** HEALTH CHECK FAILURE: while reading file '${this.healthCheckFilePath}' got ${e}`);
+                SentryCapture(e, 'Health check');
+                return res.status(500).send();
+            }
+        }
+
+        return res.send('Everything is awesome');
+    }
+}
diff --git a/lib/handlers/api/site-template-controller.ts b/lib/handlers/api/site-template-controller.ts
new file mode 100644
index 000000000..cc9ccca7b
--- /dev/null
+++ b/lib/handlers/api/site-template-controller.ts
@@ -0,0 +1,42 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import express from 'express';
+
+import {getSiteTemplates} from '../../site-templates.js';
+
+import {HttpController} from './controller.interfaces.js';
+
+export class SiteTemplateController implements HttpController {
+    createRouter(): express.Router {
+        const router = express.Router();
+        router.get('/api/siteTemplates', this.getSiteTemplates.bind(this));
+        return router;
+    }
+
+    public async getSiteTemplates(req: express.Request, res: express.Response) {
+        const templates = await getSiteTemplates();
+        res.send(templates);
+    }
+}
diff --git a/lib/handlers/api/source-controller.ts b/lib/handlers/api/source-controller.ts
index 065689401..b7a48f609 100644
--- a/lib/handlers/api/source-controller.ts
+++ b/lib/handlers/api/source-controller.ts
@@ -28,9 +28,18 @@ import _ from 'underscore';
 import {addStaticHeaders} from '../../../app.js';
 import {Source} from '../../../types/source.interfaces.js';
 
-export class SourceController {
+import {HttpController} from './controller.interfaces.js';
+
+export class SourceController implements HttpController {
     public constructor(private readonly sources: Source[]) {}
 
+    createRouter(): express.Router {
+        const router = express.Router();
+        router.get('/source/:source/list', this.listEntries.bind(this));
+        router.get('/source/:source/load/:language/:filename', this.loadEntry.bind(this));
+        return router;
+    }
+
     /**
      * Handle request to `/source/<source>/list` endpoint
      */
diff --git a/lib/handlers/assembly-documentation.ts b/lib/handlers/assembly-documentation.ts
deleted file mode 100644
index 5230864c3..000000000
--- a/lib/handlers/assembly-documentation.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (c) 2021, Compiler Explorer Authors
-// All rights reserved.
-//
-// Redistribution and use in source and binary forms, with or without
-// modification, are permitted provided that the following conditions are met:
-//
-//     * Redistributions of source code must retain the above copyright notice,
-//       this list of conditions and the following disclaimer.
-//     * Redistributions in binary form must reproduce the above copyright
-//       notice, this list of conditions and the following disclaimer in the
-//       documentation and/or other materials provided with the distribution.
-//
-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-// POSSIBILITY OF SUCH DAMAGE.
-
-import express from 'express';
-
-import {BaseAssemblyDocumentationProvider, getDocumentationProviderTypeByKey} from '../asm-docs/index.js';
-import {propsFor} from '../properties.js';
-
-const MAX_STATIC_AGE = propsFor('asm-docs')('staticMaxAgeSecs', 10);
-
-const onDocumentationProviderRequest = (
-    provider: BaseAssemblyDocumentationProvider,
-    request: express.Request,
-    response: express.Response,
-) => {
-    // If the request had no opcode parameter, we should fail. This assumes
-    // no assembly language has a __unknown_opcode instruction.
-    const instruction = (request.params.opcode || '__UNKNOWN_OPCODE').toUpperCase();
-    const information = provider.getInstructionInformation(instruction);
-    if (information === null) {
-        return response.status(404).send({error: `Unknown opcode '${instruction}'`});
-    }
-    // Accept either JSON or Plaintext Content-Type
-    const requestedContentType = request.accepts(['text', 'json']);
-    switch (requestedContentType) {
-        case 'text': {
-            response.send(information.html);
-            break;
-        }
-        case 'json': {
-            response.send(information);
-            break;
-        }
-        default: {
-            response.status(406).send({error: 'Not Acceptable'});
-            break;
-        }
-    }
-};
-
-/** Initialize API routes for assembly documentation */
-export const withAssemblyDocumentationProviders = (router: express.Router) =>
-    router.get('/asm/:arch/:opcode', (req, res) => {
-        if (MAX_STATIC_AGE > 0) {
-            res.setHeader('Cache-Control', `public, max-age=${MAX_STATIC_AGE}`);
-        }
-        const arch = req.params.arch;
-        // makeKeyedTypeGetter throws if the key is not found. We do not wish
-        // crash CE if this happens, so we catch the error and return a 404.
-        try {
-            const providerClass = getDocumentationProviderTypeByKey(arch);
-            onDocumentationProviderRequest(new providerClass(), req, res);
-        } catch {
-            res.status(404)
-                .json({error: `No documentation for '${arch}'`})
-                .send();
-        }
-    });
diff --git a/lib/handlers/health-check.ts b/lib/handlers/health-check.ts
deleted file mode 100644
index f64fcdf6f..000000000
--- a/lib/handlers/health-check.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (c) 2017, Compiler Explorer Authors
-// All rights reserved.
-//
-// Redistribution and use in source and binary forms, with or without
-// modification, are permitted provided that the following conditions are met:
-//
-//     * Redistributions of source code must retain the above copyright notice,
-//       this list of conditions and the following disclaimer.
-//     * Redistributions in binary form must reproduce the above copyright
-//       notice, this list of conditions and the following disclaimer in the
-//       documentation and/or other materials provided with the distribution.
-//
-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-// POSSIBILITY OF SUCH DAMAGE.
-
-import express from 'express';
-import fs from 'fs-extra';
-
-import {CompilationQueue} from '../compilation-queue.js';
-import {logger} from '../logger.js';
-import {SentryCapture} from '../sentry.js';
-
-import {ICompileHandler} from './compile.interfaces.js';
-
-export class HealthCheckHandler {
-    public readonly handle: (req: any, res: any) => Promise<void>;
-
-    constructor(
-        private readonly compilationQueue: CompilationQueue,
-        private readonly filePath: any,
-        private readonly compileHandler: ICompileHandler,
-        private readonly isExecutionWorker: boolean,
-    ) {
-        this.handle = this._handle.bind(this);
-    }
-
-    async _handle(req: express.Request, res: express.Response) {
-        /* wait on an empty job to pass through the compilation queue
-         * to ensure the health check will timeout if it is deadlocked
-         * we set the priority to super high here and rely on the fact
-         * that timed out jobs will auto-cancel. this health check then
-         * is just testing that _something_ is still processing jobs in
-         * the async queue.
-         *
-         * we perform the remainder of the health check outside of the
-         * job to minimize the duration that we hold an execution slot
-         */
-        await this.compilationQueue.enqueue(async () => {}, {highPriority: true});
-
-        if (!this.isExecutionWorker && !this.compileHandler.hasLanguages()) {
-            logger.error(`*** HEALTH CHECK FAILURE: no languages/compilers detected`);
-            res.status(500).end();
-            return;
-        }
-
-        if (!this.filePath) {
-            res.send('Everything is awesome');
-            return;
-        }
-
-        try {
-            const content = await fs.readFile(this.filePath);
-            if (content.length === 0) throw new Error('File is empty');
-            res.set('Content-Type', 'text/html');
-            res.send(content);
-        } catch (e) {
-            logger.error(`*** HEALTH CHECK FAILURE: while reading file '${this.filePath}' got ${e}`);
-            SentryCapture(e, 'Health check');
-            res.status(500).end();
-        }
-    }
-}
diff --git a/lib/handlers/site-templates.ts b/lib/site-templates.ts
similarity index 96%
rename from lib/handlers/site-templates.ts
rename to lib/site-templates.ts
index f65c54298..56068a1af 100644
--- a/lib/handlers/site-templates.ts
+++ b/lib/site-templates.ts
@@ -27,7 +27,7 @@ import path from 'node:path';
 
 import _ from 'underscore';
 
-import {SiteTemplatesType} from '../../types/features/site-templates.interfaces.js';
+import {SiteTemplatesType} from '../types/features/site-templates.interfaces.js';
 
 let siteTemplates: SiteTemplatesType;
 
diff --git a/static/panes/diff.ts b/static/panes/diff.ts
index 32511cf7b..3cb27a7e9 100644
--- a/static/panes/diff.ts
+++ b/static/panes/diff.ts
@@ -202,6 +202,9 @@ export class Diff extends MonacoPane<monaco.editor.IStandaloneDiffEditor, DiffSt
     constructor(hub: Hub, container: Container, state: MonacoPaneState & DiffState) {
         super(hub, container, state);
 
+        // note: keep this hacky line, properties will be filled in later (1 by 1)
+        this.selectize = {} as any;
+
         this.lhs = new DiffStateObject(
             state.lhs,
             monaco.editor.createModel('', 'asm'),


Test patch:
diff --git a/test/handlers/api-tests.ts b/test/handlers/api-tests.ts
index e809a25f6..92194b165 100644
--- a/test/handlers/api-tests.ts
+++ b/test/handlers/api-tests.ts
@@ -183,7 +183,4 @@ describe('API handling', () => {
             .expect(422, {exit: 3, answer: "Style 'bad-base' is not supported"})
             .expect('Content-Type', /json/);
     });
-    it('should respond to plain site template requests', async () => {
-        await request(app).get('/api/siteTemplates').expect(200).expect('Content-Type', /json/);
-    });
 });
diff --git a/test/handlers/asm-docs-tests.ts b/test/handlers/asm-docs-tests.ts
index 46f8a1738..168557b7c 100644
--- a/test/handlers/asm-docs-tests.ts
+++ b/test/handlers/asm-docs-tests.ts
@@ -26,7 +26,7 @@ import express from 'express';
 import request from 'supertest';
 import {beforeAll, describe, expect, it} from 'vitest';
 
-import {withAssemblyDocumentationProviders} from '../../lib/handlers/assembly-documentation.js';
+import {AssemblyDocumentationController} from '../../lib/handlers/api/assembly-documentation-controller.js';
 
 /** Test matrix of architecture to [opcode, tooptip, html, url] */
 export const TEST_MATRIX: Record<PropertyKey, [string, string, string, string][]> = {
@@ -111,9 +111,8 @@ describe('Assembly Documentation API', () => {
 
     beforeAll(() => {
         app = express();
-        const router = express.Router();
-        withAssemblyDocumentationProviders(router);
-        app.use('/api', router);
+        const controller = new AssemblyDocumentationController();
+        app.use('/', controller.createRouter());
     });
 
     it('should return 404 for unknown architecture', async () => {
diff --git a/test/handlers/health-check-tests.ts b/test/handlers/health-check-tests.ts
index 426de43bd..7aa7185ae 100644
--- a/test/handlers/health-check-tests.ts
+++ b/test/handlers/health-check-tests.ts
@@ -28,11 +28,11 @@ import request from 'supertest';
 import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
 
 import {CompilationQueue} from '../../lib/compilation-queue.js';
-import {HealthCheckHandler} from '../../lib/handlers/health-check.js';
+import {HealthcheckController} from '../../lib/handlers/api/healthcheck-controller.js';
 
 describe('Health checks', () => {
-    let app;
-    let compilationQueue;
+    let app: express.Express;
+    let compilationQueue: CompilationQueue;
 
     beforeEach(() => {
         const compileHandlerMock = {
@@ -40,26 +40,28 @@ describe('Health checks', () => {
         };
         compilationQueue = new CompilationQueue(1, 0, 0);
         app = express();
-        app.use('/hc', new HealthCheckHandler(compilationQueue, '', compileHandlerMock, false).handle);
+        const controller = new HealthcheckController(compilationQueue, null, compileHandlerMock, false);
+        app.use(controller.createRouter());
     });
 
     it('should respond with OK', async () => {
-        await request(app).get('/hc').expect(200, 'Everything is awesome');
+        await request(app).get('/healthcheck').expect(200, 'Everything is awesome');
     });
 
     it('should use compilation queue', async () => {
         let count = 0;
+        // @ts-expect-error: bypass the private _queue property
         compilationQueue._queue.on('active', () => {
             count++;
         });
-        await request(app).get('/hc');
+        await request(app).get('/healthcheck');
         expect(count).toEqual(1);
     });
 });
 
 describe('Health checks without lang/comp', () => {
-    let app;
-    let compilationQueue;
+    let app: express.Express;
+    let compilationQueue: CompilationQueue;
 
     beforeEach(() => {
         const compileHandlerMock = {
@@ -67,17 +69,18 @@ describe('Health checks without lang/comp', () => {
         };
         compilationQueue = new CompilationQueue(1, 0, 0);
         app = express();
-        app.use('/hc', new HealthCheckHandler(compilationQueue, '', compileHandlerMock, false).handle);
+        const controller = new HealthcheckController(compilationQueue, null, compileHandlerMock, false);
+        app.use(controller.createRouter());
     });
 
     it('should respond with error', async () => {
-        await request(app).get('/hc').expect(500);
+        await request(app).get('/healthcheck').expect(500);
     });
 });
 
 describe('Health checks without lang/comp but in execution worker mode', () => {
-    let app;
-    let compilationQueue;
+    let app: express.Express;
+    let compilationQueue: CompilationQueue;
 
     beforeEach(() => {
         const compileHandlerMock = {
@@ -85,16 +88,18 @@ describe('Health checks without lang/comp but in execution worker mode', () => {
         };
         compilationQueue = new CompilationQueue(1, 0, 0);
         app = express();
-        app.use('/hc', new HealthCheckHandler(compilationQueue, '', compileHandlerMock, true).handle);
+        const controller = new HealthcheckController(compilationQueue, null, compileHandlerMock, true);
+        app.use(controller.createRouter());
     });
 
     it('should respond with ok', async () => {
-        await request(app).get('/hc').expect(200);
+        await request(app).get('/healthcheck').expect(200);
     });
 });
 
 describe('Health checks on disk', () => {
-    let app;
+    let healthyApp: express.Express;
+    let unhealthyApp: express.Express;
 
     beforeAll(() => {
         const compileHandlerMock = {
@@ -102,9 +107,13 @@ describe('Health checks on disk', () => {
         };
         const compilationQueue = new CompilationQueue(1, 0, 0);
 
-        app = express();
-        app.use('/hc', new HealthCheckHandler(compilationQueue, '/fake/.nonexist', compileHandlerMock, false).handle);
-        app.use('/hc2', new HealthCheckHandler(compilationQueue, '/fake/.health', compileHandlerMock, false).handle);
+        healthyApp = express();
+        const hc1 = new HealthcheckController(compilationQueue, '/fake/.health', compileHandlerMock, false);
+        healthyApp.use(hc1.createRouter());
+
+        unhealthyApp = express();
+        const hc2 = new HealthcheckController(compilationQueue, '/fake/.nonexist', compileHandlerMock, false);
+        unhealthyApp.use(hc2.createRouter());
 
         mockfs({
             '/fake': {
@@ -118,10 +127,13 @@ describe('Health checks on disk', () => {
     });
 
     it('should respond with 500 when file not found', async () => {
-        await request(app).get('/hc').expect(500);
+        await request(unhealthyApp).get('/healthcheck').expect(500);
     });
 
     it('should respond with OK and file contents when found', async () => {
-        await request(app).get('/hc2').expect(200, 'Everything is fine');
+        await request(healthyApp)
+            .get('/healthcheck')
+            .expect(200, 'Everything is fine')
+            .expect('content-type', /text\/html/);
     });
 });
diff --git a/test/handlers/site-templates-tests.ts b/test/handlers/site-templates-tests.ts
index e9d0fa112..c1875add4 100644
--- a/test/handlers/site-templates-tests.ts
+++ b/test/handlers/site-templates-tests.ts
@@ -1,12 +1,26 @@
-import {describe, expect, it} from 'vitest';
+import express from 'express';
+import request from 'supertest';
+import {beforeAll, describe, expect, it} from 'vitest';
 
-import {getSiteTemplates} from '../../lib/handlers/site-templates.js';
+import {SiteTemplateController} from '../../lib/handlers/api/site-template-controller.js';
+import {getSiteTemplates} from '../../lib/site-templates.js';
 
 describe('Site Templates Backend', () => {
+    let app: express.Express;
+    beforeAll(() => {
+        app = express();
+        const controller = new SiteTemplateController();
+        app.use('/', controller.createRouter());
+    });
+
     it('should load site templates properly', async () => {
         const templates = await getSiteTemplates();
         // not super comprehensive
         expect(templates.meta).toHaveProperty('meta.screenshot_dimensions');
         expect(Object.entries(templates.templates).length).toBeTruthy();
     });
+
+    it('should respond to plain site template requests', async () => {
+        await request(app).get('/api/siteTemplates').expect(200).expect('Content-Type', /json/);
+    });
 });
diff --git a/test/handlers/source-tests.ts b/test/handlers/source-tests.ts
index a8708a1a7..61e95e005 100644
--- a/test/handlers/source-tests.ts
+++ b/test/handlers/source-tests.ts
@@ -38,8 +38,7 @@ describe('Sources', () => {
             load: (lang, name) => Promise.resolve({file: `File called ${name} in ${lang}`}),
         },
     ]);
-    app.use('/source/:source/list', handler.listEntries.bind(handler));
-    app.use('/source/:source/load/:language/:filename', handler.loadEntry.bind(handler));
+    app.use('/', handler.createRouter());
 
     it('should list', async () => {
         await request(app)