diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7a5f5c5cb13b19358ca16e945061f04b65cddd4a
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,23 @@
+name: Test
+on: push
+
+jobs:
+  test:
+    name: Test
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+      - name: Setup Deno
+        uses: denolib/setup-deno@v2
+        with:
+            deno-version: v1.x
+      - name: Install dependencies
+        run: |
+          npm ci
+          npm run build --if-present
+      - name: Run tests
+        run: |
+          npm run test
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..5f8562c56cf1d9a6e9687c32eb7b47a695b80709
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,9 @@
+Install `mkdocs` and `mkdocs-material` with `pip`.
+
+```
+pip install mkdocs mkdocs-material
+```
+
+To build the documentation, run `mkdocs build` in the root directory of the repository.
+
+To serve the documentation locally, run `mkdocs serve` in the root directory of the repository.
\ No newline at end of file
diff --git a/docs/docs/templates.md b/docs/docs/templates.md
index 14432f918ed9da99fa00380d5db9d5874a3e4df2..324eb7a06a6245cd8eace448c2d53728dfe7b451 100644
--- a/docs/docs/templates.md
+++ b/docs/docs/templates.md
@@ -16,6 +16,7 @@ This means the following characters will be removed: `\ , # % & / { } * < > $ '
 This template will be used to create the note text. You can use the following syntax:
 
 - `{{title}}`: The title of the podcast episode.
+- `{{safeTitle}}`: The title of the podcast episode, but with all special characters removed (like `{{title}}` in file path templates).
 - `{{description}}`: The description of the podcast episode.
 	-  You can use `{{description:> }}` to prepend each new line with a `>` (to put the entire description in a blockquote).
 - `{{podcast}}`: The name of the podcast.
diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts
index 89bf5881c54e14722eb61f17b379b26ea3526700..7168f0da1e4c2d5042a447f4a32e477958177468 100644
--- a/src/TemplateEngine.ts
+++ b/src/TemplateEngine.ts
@@ -9,77 +9,94 @@ interface Tags {
 	[tag: string]: string | ((...args: unknown[]) => string);
 }
 
-function TemplateEngine(template: string, tags: Tags) {
-	return template.replace(/\{\{(.*?)(:\s*?.+?)?\}\}/g, (match: string, tagId: string, params: string) => {
-		const tagValue = tags[tagId.toLowerCase()];
-		if (tagValue === null || tagValue === undefined) {
-			const fuse = new Fuse(Object.keys(tags), {
-				shouldSort: true,
-				findAllMatches: false,
-				threshold: 0.4,
-				isCaseSensitive: false,
-			});
-
-			const similarTag = fuse.search(tagId);
-
-			new Notice(`Tag ${tagId} is invalid.${similarTag.length > 0 ? ` Did you mean ${similarTag[0].item}?` : ""}`);
-			return match;
-		}
-
-		if (typeof tagValue === 'function') {
-			if (params) {
-				// Remove initial colon with splice.
-				const splitParams = params.slice(1).split(',');
-				const args = Array.isArray(splitParams) ? splitParams : [params];
-				
-				return tagValue(...args);
+type AddTagFn = (tag: Lowercase<string>, value: string | ((...args: unknown[]) => string)) => void;
+type ReplacerFn = (template: string) => string;
+
+function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
+	const tags: Tags = {};
+
+	function addTag(tag: Lowercase<string>, value: string | ((...args: unknown[]) => string)): void {
+		tags[tag] = value;
+	}
+	
+	function replacer(template: string): string {
+		return template.replace(/\{\{(.*?)(:\s*?.+?)?\}\}/g, (match: string, tagId: string, params: string) => {
+			const tagValue = tags[tagId.toLowerCase()];
+			if (tagValue === null || tagValue === undefined) {
+				const fuse = new Fuse(Object.keys(tags), {
+					shouldSort: true,
+					findAllMatches: false,
+					threshold: 0.4,
+					isCaseSensitive: false,
+				});
+
+				const similarTag = fuse.search(tagId);
+
+				new Notice(`Tag ${tagId} is invalid.${similarTag.length > 0 ? ` Did you mean ${similarTag[0].item}?` : ""}`);
+				return match;
 			}
 
-			return tagValue();
-		}
+			if (typeof tagValue === 'function') {
+				if (params) {
+					// Remove initial colon with splice.
+					const splitParams = params.slice(1).split(',');
+					const args = Array.isArray(splitParams) ? splitParams : [params];
+				
+					return tagValue(...args);
+				}
+
+				return tagValue();
+			}
 		
-		return tagValue;
-	});
+			return tagValue;
+		});
+	}
+
+	return [replacer, addTag] as const;
 }
 
+
 export function NoteTemplateEngine(template: string, episode: Episode) {
-	return TemplateEngine(template, {
-		"title": episode.title,
-		"description": (prependToLines?: string) => {
+	const [replacer, addTag] = useTemplateEngine();
+
+	addTag('title', episode.title);
+	addTag('description', (prependToLines?: string) => {
 			if (prependToLines) {
 				return htmlToMarkdown(episode.description)
 					.split("\n")
-					.map(prepend(prependToLines))
+					.map((str) => `${prependToLines}${str}`)
 					.join("\n")
 			}
 
 			return htmlToMarkdown(episode.description)
-		},
-		"url": episode.url,
-		"date": (format?: string) => episode.episodeDate ?
+		});
+	addTag('safetitle', replaceIllegalFileNameCharactersInString(episode.title));
+	addTag('url', episode.url);
+	addTag('date', (format?: string) => episode.episodeDate ?
 			window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD")
-			: "",
-		"podcast": episode.podcastName,
-		"artwork": episode.artworkUrl ?? "",
-	});
-}
+			: "");
+	addTag('podcast', episode.podcastName);
+	addTag('artwork', episode.artworkUrl ?? "");
 
-function prepend(prepend: string) {
-	return (str: string) => `${prepend}${str}`;
+	return replacer(template);
 }
 
 export function TimestampTemplateEngine(template: string) {
-	return TemplateEngine(template, {
-		"time": (format?: string) => get(plugin).api.getPodcastTimeFormatted(format ?? "HH:mm:ss"),
-		"linktime": (format?: string) => get(plugin).api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true),
-	});
+	const [replacer, addTag] = useTemplateEngine();
+
+	addTag('time', (format?: string) => get(plugin).api.getPodcastTimeFormatted(format ?? "HH:mm:ss"))
+	addTag('linktime', (format?: string) => get(plugin).api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true))
+	
+	return replacer(template);
 }
 
 export function FilePathTemplateEngine(template: string, episode: Episode) {
-	return TemplateEngine(template, {
-		"title": replaceIllegalFileNameCharactersInString(episode.title),
-		"podcast": replaceIllegalFileNameCharactersInString(episode.podcastName),
-	});
+	const [replacer, addTag] = useTemplateEngine();
+
+	addTag('title', replaceIllegalFileNameCharactersInString(episode.title));
+	addTag('podcast', replaceIllegalFileNameCharactersInString(episode.podcastName));
+
+	return replacer(template);
 }
 
 export function DownloadPathTemplateEngine(template: string, episode: Episode) {
@@ -89,10 +106,12 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) {
 		template.replace(templateExtension, '') :
 		template;
 
-	return TemplateEngine(templateWithoutExtension, {
-		"title": replaceIllegalFileNameCharactersInString(episode.title),
-		"podcast": replaceIllegalFileNameCharactersInString(episode.podcastName),
-	});
+	const [replacer, addTag] = useTemplateEngine();
+
+	addTag("title", replaceIllegalFileNameCharactersInString(episode.title));
+	addTag("podcast", replaceIllegalFileNameCharactersInString(episode.podcastName));
+
+	return replacer(templateWithoutExtension);
 }
 
 function replaceIllegalFileNameCharactersInString(string: string) {
diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts
index b600cd85621520067fa3524f5b1f1bbab4667a80..0d417dfa19121afcf349a9013ea18941765843e1 100644
--- a/src/createPodcastNote.ts
+++ b/src/createPodcastNote.ts
@@ -3,6 +3,7 @@ import { FilePathTemplateEngine, NoteTemplateEngine } from "./TemplateEngine";
 import { Episode } from "./types/Episode";
 import { get } from "svelte/store";
 import { plugin } from "./store";
+import addExtension from "./utility/addExtension";
 
 export default async function createPodcastNote(
 	episode: Episode
@@ -14,43 +15,19 @@ export default async function createPodcastNote(
 		episode
 	);
 
-	const filePathDotMd = filePath.endsWith(".md")
-		? filePath
-		: `${filePath}.md`;
+	const filePathDotMd = addExtension(filePath, "md");
 
 	const content = NoteTemplateEngine(
 		pluginInstance.settings.note.template,
 		episode
 	);
 
-	const createOrGetFile: (
-		path: string,
-		content: string
-	) => Promise<TFile> = async (path: string, content: string) => {
-		const file = getPodcastNote(episode);
-
-		if (file) {
-			new Notice(
-				`Note for "${pluginInstance.api.podcast.title}" already exists`
-			);
-			return file;
-		}
-
-		const foldersInPath = path.split("/").slice(0, -1);
-		for (let i = 0; i < foldersInPath.length; i++) {
-			const folderPath = foldersInPath.slice(0, i + 1).join("/");
-			const folder = app.vault.getAbstractFileByPath(folderPath);
-
-			if (!folder) {
-				await app.vault.createFolder(folderPath);
-			}
-		}
-
-		return await app.vault.create(path, content);
-	};
-
 	try {
-		const file = await createOrGetFile(filePathDotMd, content);
+		const file = await createFileIfNotExists(
+			filePathDotMd,
+			content,
+			episode
+		);
 
 		app.workspace.getLeaf().openFile(file);
 	} catch (error) {
@@ -67,9 +44,7 @@ export function getPodcastNote(episode: Episode): TFile | null {
 		episode
 	);
 
-	const filePathDotMd = filePath.endsWith(".md")
-		? filePath
-		: `${filePath}.md`;
+	const filePathDotMd = addExtension(filePath, "md");
 	const file = app.vault.getAbstractFileByPath(filePathDotMd);
 
 	if (!file || !(file instanceof TFile)) {
@@ -89,3 +64,30 @@ export function openPodcastNote(epiosode: Episode): void {
 
 	app.workspace.getLeaf().openFile(file);
 }
+
+async function createFileIfNotExists(
+	path: string,
+	content: string,
+	episode: Episode,
+	createFolder = true
+): Promise<TFile> {
+	const file = getPodcastNote(episode);
+
+	if (file) {
+		new Notice(`Note for "${episode.title}" already exists`);
+
+		return file;
+	}
+
+	const foldersInPath = path.split("/").slice(0, -1);
+	for (let i = 0; i < foldersInPath.length; i++) {
+		const folderPath = foldersInPath.slice(0, i + 1).join("/");
+		const folder = app.vault.getAbstractFileByPath(folderPath);
+
+		if (!folder && createFolder) {
+			await app.vault.createFolder(folderPath);
+		}
+	}
+
+	return await app.vault.create(path, content);
+}
diff --git a/src/ui/common/Progressbar.test.ts b/src/ui/common/Progressbar.test.ts
index 93cf0dad9e6792afbc0bee3636e1dca8e86ce0eb..2e4d88b8a141cd6c400f1f64cbd18cce14ec5d77 100644
--- a/src/ui/common/Progressbar.test.ts
+++ b/src/ui/common/Progressbar.test.ts
@@ -6,5 +6,5 @@ import Progressbar from './Progressbar.svelte';
 test('should render', () => {
     const { container } = render(Progressbar, { props: { value: 0, max: 100}});
 
-    expect(container).toBeTruthy();
+    expect(container).toBeVisible();
 });
\ No newline at end of file
diff --git a/src/utility/addExtension.test.ts b/src/utility/addExtension.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1699b126d7848889e8fe83a364d21acf104a259e
--- /dev/null
+++ b/src/utility/addExtension.test.ts
@@ -0,0 +1,17 @@
+import { expect, describe, test } from "vitest";
+import addExtension from "./addExtension";
+
+describe("addExtension", () => {
+    test("adds an extension to a file path", () => {
+        expect(addExtension("path/to/file", "md")).toBe("path/to/file.md");
+    });
+        
+    test("does not add an extension if the file path already has one", () => {
+        expect(addExtension("path/to/file.md", "md")).toBe("path/to/file.md");
+    });
+
+    test("works with and without dot in extension", () => {
+        expect(addExtension("path/to/file", ".md")).toBe("path/to/file.md");
+        expect(addExtension("path/to/file.md", "md")).toBe("path/to/file.md");
+    });
+});
\ No newline at end of file
diff --git a/src/utility/addExtension.ts b/src/utility/addExtension.ts
new file mode 100644
index 0000000000000000000000000000000000000000..67b2c2aa8f23d47b362daa23f64076550b462960
--- /dev/null
+++ b/src/utility/addExtension.ts
@@ -0,0 +1,5 @@
+export default function addExtension(path: string, extension: string): string {
+	const ext = extension.startsWith(".") ? extension : `.${extension}`;
+
+	return path.endsWith(ext) ? path : `${path}${ext}`;
+}