Repository: solid/community-server (ts)

Issue:
favicon broken when running standalone
The default configuration created in https://github.com/solid/community-server/pull/457 does not work, because `./templates/root/favicon.ico` is a relative path.

We probably want a placeholder variable like `$MODULE_PATH` in there.

Pull request description:
Allow file paths to be relative to module root
Closes #650.

I'm still not 100% fan of this solution but I can't think of anything better and it is nice that now it's easier to have paths in configs. Only other option I could think of was passing a boolean along to indicate if a path should be resolved against module root or cwd but that seems worse.

While I was working on the paths I also used this opportunity to remove the `podTemplateFolder` variable. In practice this value is always going to be the same unless an advanced user wants to do something specific. 

Patch:
diff --git a/README.md b/README.md
index 10c32b89f..ea2cc3567 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,6 @@ Additional recipes for configuring and deploying the server can be found at [sol
 | `--config, -c` | `"config/config-default.json"` | `config-default.json` stores all data in memory. If you would like to persist data to your filesystem, try `config-file.json` |
 | `--mainModulePath, -m` | | Absolute path to the package root from which ComponentJS module resolution should start. |
 | `--loggingLevel, -l` | `"info"`| |
-| `--podTemplateFolder, -t` | `"templates/pod"` | Folder containing the templates used for pod provisioning. |
 | `--rootFilePath, -f` | `"./"` | Folder to start the server in when using a file-based config. |
 | `--sparqlEndpoint, -s` | | Endpoint to call when using a SPARQL-based config. |
 | `--podConfigJson` | `"./pod-config.json"` | JSON file to store pod configuration when using a dynamic config. |
diff --git a/config/presets/cli-params.json b/config/presets/cli-params.json
index 7039e891b..689ec3726 100644
--- a/config/presets/cli-params.json
+++ b/config/presets/cli-params.json
@@ -21,10 +21,6 @@
       "@id": "urn:solid-server:default:variable:loggingLevel",
       "@type": "Variable"
     },
-    {
-      "@id": "urn:solid-server:default:variable:podTemplateFolder",
-      "@type": "Variable"
-    },
     {
       "comment": "Path to the JSON file used to store configuration for dynamic pods.",
       "@id": "urn:solid-server:default:variable:podConfigJson",
diff --git a/config/presets/http.json b/config/presets/http.json
index af0c58f66..c97d33bcf 100644
--- a/config/presets/http.json
+++ b/config/presets/http.json
@@ -51,7 +51,7 @@
       "StaticAssetHandler:_assets": [
         {
           "StaticAssetHandler:_assets_key": "/favicon.ico",
-          "StaticAssetHandler:_assets_value": "./templates/root/favicon.ico"
+          "StaticAssetHandler:_assets_value": "$PACKAGE_ROOT/templates/root/favicon.ico"
         }
       ]
     }
diff --git a/config/presets/pod-dynamic.json b/config/presets/pod-dynamic.json
index 5153d9b24..5fbd3b909 100644
--- a/config/presets/pod-dynamic.json
+++ b/config/presets/pod-dynamic.json
@@ -79,9 +79,7 @@
       "ConfigPodManager:_resourcesGenerator": {
         "@id": "urn:solid-server:default:ResourcesGenerator",
         "@type": "TemplatedResourcesGenerator",
-        "TemplatedResourcesGenerator:_templateFolder": {
-          "@id": "urn:solid-server:default:variable:podTemplateFolder"
-        },
+        "TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/templates/pod",
         "TemplatedResourcesGenerator:_factory": {
           "@type": "ExtensionBasedMapperFactory"
         },
diff --git a/config/presets/pod-management.json b/config/presets/pod-management.json
index 83b72b42a..769c90966 100644
--- a/config/presets/pod-management.json
+++ b/config/presets/pod-management.json
@@ -4,9 +4,7 @@
     {
       "@id": "urn:solid-server:default:ResourcesGenerator",
       "@type": "TemplatedResourcesGenerator",
-      "TemplatedResourcesGenerator:_templateFolder": {
-        "@id": "urn:solid-server:default:variable:podTemplateFolder"
-      },
+      "TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/templates/pod",
       "TemplatedResourcesGenerator:_factory": {
         "@type": "ExtensionBasedMapperFactory"
       },
diff --git a/src/init/CliRunner.ts b/src/init/CliRunner.ts
index 4f504aafa..dc0599288 100644
--- a/src/init/CliRunner.ts
+++ b/src/init/CliRunner.ts
@@ -58,7 +58,6 @@ export class CliRunner {
         config: { type: 'string', alias: 'c' },
         loggingLevel: { type: 'string', alias: 'l', default: 'info' },
         mainModulePath: { type: 'string', alias: 'm' },
-        podTemplateFolder: { type: 'string', alias: 't' },
         port: { type: 'number', alias: 'p', default: 3000 },
         rootFilePath: { type: 'string', alias: 'f', default: './' },
         sparqlEndpoint: { type: 'string', alias: 's' },
@@ -113,8 +112,6 @@ export class CliRunner {
       'urn:solid-server:default:variable:rootFilePath':
         this.resolveFilePath(params.rootFilePath),
       'urn:solid-server:default:variable:sparqlEndpoint': params.sparqlEndpoint,
-      'urn:solid-server:default:variable:podTemplateFolder':
-         this.resolveFilePath(params.podTemplateFolder, 'templates/pod'),
       'urn:solid-server:default:variable:podConfigJson':
         this.resolveFilePath(params.podConfigJson),
     };
diff --git a/src/pods/generate/TemplatedResourcesGenerator.ts b/src/pods/generate/TemplatedResourcesGenerator.ts
index 9237679ad..446b44321 100644
--- a/src/pods/generate/TemplatedResourcesGenerator.ts
+++ b/src/pods/generate/TemplatedResourcesGenerator.ts
@@ -8,7 +8,7 @@ import type {
   FileIdentifierMapperFactory,
   ResourceLink,
 } from '../../storage/mapping/FileIdentifierMapper';
-import { joinFilePath, isContainerIdentifier } from '../../util/PathUtil';
+import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil';
 import type { Resource, ResourcesGenerator } from './ResourcesGenerator';
 import type { TemplateEngine } from './TemplateEngine';
 import Dict = NodeJS.Dict;
@@ -18,6 +18,9 @@ import Dict = NodeJS.Dict;
  * The template folder structure will be kept.
  * Folders will be interpreted as containers and files as documents.
  * A FileIdentifierMapper will be used to generate identifiers that correspond to the relative structure.
+ *
+ * A relative `templateFolder` is resolved relative to cwd,
+ * unless it's preceded by $PACKAGE_ROOT/, e.g. $PACKAGE_ROOT/foo/bar.
  */
 export class TemplatedResourcesGenerator implements ResourcesGenerator {
   private readonly templateFolder: string;
@@ -33,7 +36,7 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
    * @param engine - Template engine for generating the resources.
    */
   public constructor(templateFolder: string, factory: FileIdentifierMapperFactory, engine: TemplateEngine) {
-    this.templateFolder = templateFolder;
+    this.templateFolder = resolveAssetPath(templateFolder);
     this.factory = factory;
     this.engine = engine;
   }
diff --git a/src/server/middleware/StaticAssetHandler.ts b/src/server/middleware/StaticAssetHandler.ts
index c6293c5e7..b91d31c93 100644
--- a/src/server/middleware/StaticAssetHandler.ts
+++ b/src/server/middleware/StaticAssetHandler.ts
@@ -5,7 +5,7 @@ import { getLoggerFor } from '../../logging/LogUtil';
 import { APPLICATION_OCTET_STREAM } from '../../util/ContentTypes';
 import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
 import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
-import { joinFilePath } from '../../util/PathUtil';
+import { joinFilePath, resolveAssetPath } from '../../util/PathUtil';
 import { pipeSafely } from '../../util/StreamUtil';
 import type { HttpHandlerInput } from '../HttpHandler';
 import { HttpHandler } from '../HttpHandler';
@@ -13,6 +13,9 @@ import type { HttpRequest } from '../HttpRequest';
 
 /**
  * Handler that serves static resources on specific paths.
+ * Relative file paths are assumed to be relative to cwd.
+ * Relative file paths can be preceded by $PACKAGE_ROOT/, e.g. $PACKAGE_ROOT/foo/bar,
+ * in case they need to be relative to the module root.
  */
 export class StaticAssetHandler extends HttpHandler {
   private readonly mappings: Record<string, string>;
@@ -26,7 +29,10 @@ export class StaticAssetHandler extends HttpHandler {
    */
   public constructor(assets: Record<string, string>) {
     super();
-    this.mappings = { ...assets };
+    this.mappings = {};
+    for (const [ url, path ] of Object.entries(assets)) {
+      this.mappings[url] = resolveAssetPath(path);
+    }
     this.pathMatcher = this.createPathMatcher(assets);
   }
 
diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts
index 60fb6bb00..7bc41ea64 100644
--- a/src/util/PathUtil.ts
+++ b/src/util/PathUtil.ts
@@ -165,3 +165,23 @@ export function createSubdomainRegexp(baseUrl: string): RegExp {
   const { scheme, rest } = extractScheme(baseUrl);
   return new RegExp(`^${scheme}(?:([^/]+)\\.)?${rest}`, 'u');
 }
+
+/**
+ * Returns the folder corresponding to the root of the Community Solid Server module
+ */
+export function getModuleRoot(): string {
+  return joinFilePath(__dirname, '../../');
+}
+
+/**
+ * Converts file path inputs into absolute paths.
+ * Works similar to `absoluteFilePath` but paths that start with '$PACKAGE_ROOT/'
+ * will be relative to the module directory instead of the cwd.
+ */
+export function resolveAssetPath(path: string): string {
+  const modulePath = '$PACKAGE_ROOT/';
+  if (path.startsWith(modulePath)) {
+    return joinFilePath(getModuleRoot(), path.slice(modulePath.length));
+  }
+  return absoluteFilePath(path);
+}


Test patch:
diff --git a/test/integration/DynamicPods.test.ts b/test/integration/DynamicPods.test.ts
index 361786f99..b8edc408e 100644
--- a/test/integration/DynamicPods.test.ts
+++ b/test/integration/DynamicPods.test.ts
@@ -34,12 +34,11 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
       'urn:solid-server:default:variable:baseUrl': baseUrl,
       'urn:solid-server:default:variable:port': port,
       'urn:solid-server:default:variable:rootFilePath': rootFilePath,
-      'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'),
       'urn:solid-server:default:variable:podConfigJson': podConfigJson,
     };
 
     // Need to make sure the temp folder exists so the podConfigJson can be written to it
-    mkdirSync(rootFilePath);
+    mkdirSync(rootFilePath, { recursive: true });
 
     // Create and initialize the HTTP handler and related components
     const instances = await instantiateFromConfig(
diff --git a/test/integration/PodCreation.test.ts b/test/integration/PodCreation.test.ts
index 281bf4e8c..6aba97e74 100644
--- a/test/integration/PodCreation.test.ts
+++ b/test/integration/PodCreation.test.ts
@@ -1,7 +1,6 @@
 import type { Server } from 'http';
 import fetch from 'cross-fetch';
 import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
-import { joinFilePath } from '../../src/util/PathUtil';
 import { readableToString } from '../../src/util/StreamUtil';
 import { instantiateFromConfig } from './Config';
 
@@ -17,7 +16,6 @@ describe('A server with a pod handler', (): void => {
       'urn:solid-server:default:ServerFactory', 'server-without-auth.json', {
         'urn:solid-server:default:variable:port': port,
         'urn:solid-server:default:variable:baseUrl': baseUrl,
-        'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'),
       },
     ) as HttpServerFactory;
     server = factory.startServer(port);
diff --git a/test/integration/ServerFetch.test.ts b/test/integration/ServerFetch.test.ts
index 3daa07b0a..452fd3ffd 100644
--- a/test/integration/ServerFetch.test.ts
+++ b/test/integration/ServerFetch.test.ts
@@ -2,7 +2,6 @@ import type { Server } from 'http';
 import fetch from 'cross-fetch';
 import type { Initializer } from '../../src/init/Initializer';
 import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
-import { joinFilePath } from '../../src/util/PathUtil';
 import { instantiateFromConfig } from './Config';
 
 const port = 6004;
@@ -19,7 +18,6 @@ describe('A Solid server', (): void => {
       'urn:solid-server:test:Instances', 'server-memory.json', {
         'urn:solid-server:default:variable:port': port,
         'urn:solid-server:default:variable:baseUrl': baseUrl,
-        'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'),
       },
     ) as Record<string, any>;
     ({ factory, initializer } = instances);
diff --git a/test/integration/Subdomains.test.ts b/test/integration/Subdomains.test.ts
index 06dd88b4e..28469faca 100644
--- a/test/integration/Subdomains.test.ts
+++ b/test/integration/Subdomains.test.ts
@@ -3,7 +3,6 @@ import fetch from 'cross-fetch';
 import type { Initializer } from '../../src/init/Initializer';
 import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
 import type { ResourceStore } from '../../src/storage/ResourceStore';
-import { joinFilePath } from '../../src/util/PathUtil';
 import { getTestFolder, instantiateFromConfig, removeFolder } from './Config';
 
 const port = 6005;
@@ -35,7 +34,6 @@ describe.each(stores)('A subdomain server with %s', (name, { storeUrn, teardown
       'urn:solid-server:default:variable:baseUrl': baseUrl,
       'urn:solid-server:default:variable:port': port,
       'urn:solid-server:default:variable:rootFilePath': rootFilePath,
-      'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'),
     };
     const internalStore = await instantiateFromConfig(
       storeUrn,
diff --git a/test/integration/WebSocketsProtocol.test.ts b/test/integration/WebSocketsProtocol.test.ts
index 51307b219..504f59fed 100644
--- a/test/integration/WebSocketsProtocol.test.ts
+++ b/test/integration/WebSocketsProtocol.test.ts
@@ -16,7 +16,6 @@ describe('A server with the Solid WebSockets API behind a proxy', (): void => {
       'urn:solid-server:default:ServerFactory', 'server-without-auth.json', {
         'urn:solid-server:default:variable:port': port,
         'urn:solid-server:default:variable:baseUrl': 'https://example.pod/',
-        'urn:solid-server:default:variable:podTemplateFolder': 'templates/pod',
       },
     ) as HttpServerFactory;
     server = factory.startServer(port);
diff --git a/test/integration/config/server-dynamic-unsafe.json b/test/integration/config/server-dynamic-unsafe.json
index dbfe40f21..f754d41bb 100644
--- a/test/integration/config/server-dynamic-unsafe.json
+++ b/test/integration/config/server-dynamic-unsafe.json
@@ -76,6 +76,10 @@
       "BaseUrlRouterRule:_baseStore": {
         "@id": "urn:solid-server:default:MemoryResourceStore"
       }
+    },
+    {
+      "@id": "urn:solid-server:default:ResourcesGenerator",
+      "TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/test/assets/templates"
     }
   ]
 }
diff --git a/test/integration/config/server-subdomains-unsafe.json b/test/integration/config/server-subdomains-unsafe.json
index 3c41c74dc..16bb74433 100644
--- a/test/integration/config/server-subdomains-unsafe.json
+++ b/test/integration/config/server-subdomains-unsafe.json
@@ -79,6 +79,10 @@
         "@id": "urn:solid-server:default:variable:store"
       }
     },
+    {
+      "@id": "urn:solid-server:default:ResourcesGenerator",
+      "TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/test/assets/templates"
+    },
     {
       "@id": "urn:solid-server:default:variable:store",
       "@type": "Variable"
diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json
index abcffb8a5..60e8f5912 100644
--- a/test/integration/config/server-without-auth.json
+++ b/test/integration/config/server-without-auth.json
@@ -48,6 +48,10 @@
       "PassthroughStore:_source": {
         "@id": "urn:solid-server:default:MemoryResourceStore"
       }
+    },
+    {
+      "@id": "urn:solid-server:default:ResourcesGenerator",
+      "TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/test/assets/templates"
     }
   ]
 }
diff --git a/test/unit/init/CliRunner.test.ts b/test/unit/init/CliRunner.test.ts
index 8c057e395..bbb57145a 100644
--- a/test/unit/init/CliRunner.test.ts
+++ b/test/unit/init/CliRunner.test.ts
@@ -60,7 +60,6 @@ describe('CliRunner', (): void => {
           'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
           'urn:solid-server:default:variable:sparqlEndpoint': undefined,
           'urn:solid-server:default:variable:loggingLevel': 'info',
-          'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../../../templates/pod'),
           'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
         },
       },
@@ -80,7 +79,6 @@ describe('CliRunner', (): void => {
         '-m', 'module/path',
         '-p', '4000',
         '-s', 'http://localhost:5000/sparql',
-        '-t', 'templates',
         '--podConfigJson', '/different-path.json',
       ],
     });
@@ -104,7 +102,6 @@ describe('CliRunner', (): void => {
         variables: {
           'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
           'urn:solid-server:default:variable:loggingLevel': 'debug',
-          'urn:solid-server:default:variable:podTemplateFolder': '/var/cwd/templates',
           'urn:solid-server:default:variable:port': 4000,
           'urn:solid-server:default:variable:rootFilePath': '/root',
           'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
@@ -122,7 +119,6 @@ describe('CliRunner', (): void => {
         '--config', 'myconfig.json',
         '--loggingLevel', 'debug',
         '--mainModulePath', 'module/path',
-        '--podTemplateFolder', 'templates',
         '--port', '4000',
         '--rootFilePath', 'root',
         '--sparqlEndpoint', 'http://localhost:5000/sparql',
@@ -149,7 +145,6 @@ describe('CliRunner', (): void => {
         variables: {
           'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
           'urn:solid-server:default:variable:loggingLevel': 'debug',
-          'urn:solid-server:default:variable:podTemplateFolder': '/var/cwd/templates',
           'urn:solid-server:default:variable:port': 4000,
           'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root',
           'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
@@ -170,7 +165,6 @@ describe('CliRunner', (): void => {
       '-m', 'module/path',
       '-p', '4000',
       '-s', 'http://localhost:5000/sparql',
-      '-t', 'templates',
       '--podConfigJson', '/different-path.json',
     ];
 
@@ -195,7 +189,6 @@ describe('CliRunner', (): void => {
         variables: {
           'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
           'urn:solid-server:default:variable:loggingLevel': 'debug',
-          'urn:solid-server:default:variable:podTemplateFolder': '/var/cwd/templates',
           'urn:solid-server:default:variable:port': 4000,
           'urn:solid-server:default:variable:rootFilePath': '/root',
           'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
diff --git a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts
index 7b4c92bab..490feb10b 100644
--- a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts
+++ b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts
@@ -38,7 +38,7 @@ async function genToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
 }
 
 describe('A TemplatedResourcesGenerator', (): void => {
-  const rootFilePath = 'templates/pod';
+  const rootFilePath = '/templates/pod';
   // Using handlebars engine since it's smaller than any possible dummy
   const generator = new TemplatedResourcesGenerator(rootFilePath, new DummyFactory(), new HandlebarsTemplateEngine());
   let cache: { data: any };
diff --git a/test/unit/server/middleware/StaticAssetHandler.test.ts b/test/unit/server/middleware/StaticAssetHandler.test.ts
index 7b8ca071c..1d7690446 100644
--- a/test/unit/server/middleware/StaticAssetHandler.test.ts
+++ b/test/unit/server/middleware/StaticAssetHandler.test.ts
@@ -6,15 +6,18 @@ import streamifyArray from 'streamify-array';
 import { StaticAssetHandler } from '../../../../src/server/middleware/StaticAssetHandler';
 import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
 import type { SystemError } from '../../../../src/util/errors/SystemError';
+import { getModuleRoot, joinFilePath } from '../../../../src/util/PathUtil';
 
 const createReadStream = jest.spyOn(fs, 'createReadStream')
   .mockImplementation((): any => streamifyArray([ 'file contents' ]));
 
-describe('a StaticAssetHandler', (): void => {
+describe('A StaticAssetHandler', (): void => {
   const handler = new StaticAssetHandler({
     '/foo/bar/style': '/assets/styles/bar.css',
     '/foo/bar/main': '/assets/scripts/bar.js',
     '/foo/bar/unknown': '/assets/bar.unknown',
+    '/foo/bar/cwd': 'paths/cwd.txt',
+    '/foo/bar/module': '$PACKAGE_ROOT/paths/module.txt',
     '/foo/bar/folder1/': '/assets/folders/1/',
     '/foo/bar/folder2/': '/assets/folders/2',
     '/foo/bar/folder2/subfolder/': '/assets/folders/3',
@@ -92,6 +95,30 @@ describe('a StaticAssetHandler', (): void => {
     expect(createReadStream).toHaveBeenCalledWith('/assets/bar.unknown');
   });
 
+  it('handles a request to a known URL with a relative file path.', async(): Promise<void> => {
+    const request = { method: 'GET', url: '/foo/bar/cwd' };
+    const response = createResponse({ eventEmitter: EventEmitter });
+    await handler.handleSafe({ request, response } as any);
+
+    expect(response.statusCode).toBe(200);
+    expect(response.getHeaders()).toHaveProperty('content-type', 'text/plain');
+
+    expect(createReadStream).toHaveBeenCalledTimes(1);
+    expect(createReadStream).toHaveBeenCalledWith(joinFilePath(process.cwd(), '/paths/cwd.txt'));
+  });
+
+  it('handles a request to a known URL with a relative to module file path.', async(): Promise<void> => {
+    const request = { method: 'GET', url: '/foo/bar/module' };
+    const response = createResponse({ eventEmitter: EventEmitter });
+    await handler.handleSafe({ request, response } as any);
+
+    expect(response.statusCode).toBe(200);
+    expect(response.getHeaders()).toHaveProperty('content-type', 'text/plain');
+
+    expect(createReadStream).toHaveBeenCalledTimes(1);
+    expect(createReadStream).toHaveBeenCalledWith(joinFilePath(getModuleRoot(), '/paths/module.txt'));
+  });
+
   it('throws a 404 when the asset does not exist.', async(): Promise<void> => {
     const request = { method: 'GET', url: '/foo/bar/main' };
     const response = createResponse({ eventEmitter: EventEmitter });
diff --git a/test/unit/util/PathUtil.test.ts b/test/unit/util/PathUtil.test.ts
index c3fecb63c..c6be92d3e 100644
--- a/test/unit/util/PathUtil.test.ts
+++ b/test/unit/util/PathUtil.test.ts
@@ -1,11 +1,12 @@
+import { existsSync } from 'fs';
 import {
-  absoluteFilePath,
+  absoluteFilePath, createSubdomainRegexp,
   decodeUriPathComponents,
   encodeUriPathComponents,
-  ensureTrailingSlash,
+  ensureTrailingSlash, extractScheme, getExtension, getModuleRoot, isContainerIdentifier, isContainerPath,
   joinFilePath,
-  normalizeFilePath,
-  toCanonicalUriPath,
+  normalizeFilePath, resolveAssetPath,
+  toCanonicalUriPath, trimTrailingSlashes,
 } from '../../../src/util/PathUtil';
 
 describe('PathUtil', (): void => {
@@ -52,6 +53,22 @@ describe('PathUtil', (): void => {
     });
   });
 
+  describe('#trimTrailingSlashes', (): void => {
+    it('removes all trailing slashes.', async(): Promise<void> => {
+      expect(trimTrailingSlashes('http://test.com')).toEqual('http://test.com');
+      expect(trimTrailingSlashes('http://test.com/')).toEqual('http://test.com');
+      expect(trimTrailingSlashes('http://test.com//')).toEqual('http://test.com');
+      expect(trimTrailingSlashes('http://test.com///')).toEqual('http://test.com');
+    });
+  });
+
+  describe('#getExtension', (): void => {
+    it('returns the extension of a path.', async(): Promise<void> => {
+      expect(getExtension('/a/b.txt')).toEqual('txt');
+      expect(getExtension('/a/btxt')).toEqual('');
+    });
+  });
+
   describe('#toCanonicalUriPath', (): void => {
     it('encodes only the necessary parts.', async(): Promise<void> => {
       expect(toCanonicalUriPath('/a%20path&/name')).toEqual('/a%20path%26/name');
@@ -81,4 +98,67 @@ describe('PathUtil', (): void => {
       expect(encodeUriPathComponents('/a%20path&/name?abc=def&xyz')).toEqual('/a%2520path%26/name?abc=def&xyz');
     });
   });
+
+  describe('#isContainerPath', (): void => {
+    it('returns true if the path ends with a slash.', async(): Promise<void> => {
+      expect(isContainerPath('/a/b')).toEqual(false);
+      expect(isContainerPath('/a/b/')).toEqual(true);
+    });
+  });
+
+  describe('#isContainerIdentifier', (): void => {
+    it('works af isContainerPath but for identifiers.', async(): Promise<void> => {
+      expect(isContainerIdentifier({ path: '/a/b' })).toEqual(false);
+      expect(isContainerIdentifier({ path: '/a/b/' })).toEqual(true);
+    });
+  });
+
+  describe('#extractScheme', (): void => {
+    it('splits a URL.', async(): Promise<void> => {
+      expect(extractScheme('http://test.com/foo')).toEqual({ scheme: 'http://', rest: 'test.com/foo' });
+    });
+  });
+
+  describe('#createSubdomainRegexp', (): void => {
+    it('creates a regex to match the URL and extract a subdomain.', async(): Promise<void> => {
+      const regex = createSubdomainRegexp('http://test.com/foo/');
+      expect(regex.exec('http://test.com/foo/')![1]).toBeUndefined();
+      expect(regex.exec('http://test.com/foo/bar')![1]).toBeUndefined();
+      expect(regex.exec('http://alice.test.com/foo/')![1]).toEqual('alice');
+      expect(regex.exec('http://alice.bob.test.com/foo/')![1]).toEqual('alice.bob');
+      expect(regex.exec('http://test.com/')).toBeNull();
+      expect(regex.exec('http://alicetest.com/foo/')).toBeNull();
+    });
+  });
+
+  describe('#getModuleRoot', (): void => {
+    it('returns the root folder of the module.', async(): Promise<void> => {
+      // Note that this test only makes sense as long as the dist folder is on the same level as the src folder
+      const root = getModuleRoot();
+      const packageJson = joinFilePath(root, 'package.json');
+      expect(existsSync(packageJson)).toBe(true);
+    });
+  });
+
+  describe('#resolvePathInput', (): void => {
+    it('interprets paths relative to the module root when starting with $PACKAGE_ROOT/.', async(): Promise<void> => {
+      expect(resolveAssetPath('$PACKAGE_ROOT/foo/bar')).toBe(joinFilePath(getModuleRoot(), '/foo/bar'));
+    });
+
+    it('handles ../ paths with $PACKAGE_ROOT/.', async(): Promise<void> => {
+      expect(resolveAssetPath('$PACKAGE_ROOT/foo/bar/../baz')).toBe(joinFilePath(getModuleRoot(), '/foo/baz'));
+    });
+
+    it('leaves absolute paths as they are.', async(): Promise<void> => {
+      expect(resolveAssetPath('/foo/bar/')).toBe('/foo/bar/');
+    });
+
+    it('handles other paths relative to the cwd.', async(): Promise<void> => {
+      expect(resolveAssetPath('foo/bar/')).toBe(joinFilePath(process.cwd(), 'foo/bar/'));
+    });
+
+    it('handles other paths with ../.', async(): Promise<void> => {
+      expect(resolveAssetPath('foo/bar/../baz')).toBe(joinFilePath(process.cwd(), 'foo/baz'));
+    });
+  });
 });