diff --git a/html_generator.php b/html_generator.php
index 2e492b7..a18a834 100644
--- a/html_generator.php
+++ b/html_generator.php
@@ -10,7 +10,7 @@
 			"		" . PHP_EOL .
 			"		" . PHP_EOL .
 			"		" . PHP_EOL .
-			"		" . PHP_EOL .
+			"		" . PHP_EOL .
 			"		
" . $title . "" . PHP_EOL .
 			"	" . PHP_EOL .
 			"	" . PHP_EOL;
diff --git a/output/js/constants.js b/output/js/constants.js
new file mode 100644
index 0000000..83ac3cb
--- /dev/null
+++ b/output/js/constants.js
@@ -0,0 +1,58 @@
+// This file contains definitions which help to reduce the amount
+// of redunant values in the main file, especially those that could
+// change in the foreseeable future.
+
+export const dom = {
+  tbl_communities: () => document.getElementById("tbl_communities"),
+  td_last_checked: () => document.getElementById("td_last_checked"),
+  qr_modal: (communityID) => document.getElementById(`modal_${communityID}`),
+  join_urls: () => document.getElementsByClassName("td_join_url"),
+  td_summary: () => document.getElementById("td_summary"),
+  snackbar: () => document.getElementById("copy-snackbar")
+}
+
+export const COLUMN = {
+	IDENTIFIER:   0,  LANGUAGE:     1,  NAME:         2,
+	DESCRIPTION:  3,  USERS:        4,  PREVIEW:      5,
+	QR_CODE:      6,  JOIN_URL:     7
+};
+
+// Reverse enum.
+// Takes original key-value pairs, flips them, and casefolds the new values.
+// Should correspond to #th_{} and .td_{} elements in communities table.
+export const COLUMN_LITERAL = Object.fromEntries(
+  Object.entries(COLUMN).map(([name, id]) => [id, name.toLowerCase()])
+);
+
+export const COMPARISON = {
+	GREATER: 1, EQUAL: 0, SMALLER: -1
+};
+
+export const ATTRIBUTES = {
+  SORTING: {
+    ACTIVE: 'data-sort',
+    ASCENDING: 'data-sort-asc',
+    COLUMN: 'data-sorted-by',
+    COLUMN_LITERAL: 'sorted-by'
+  }
+};
+
+export function columnAscendingByDefault(column) { 
+  return column != COLUMN.USERS; 
+}
+
+export function columnIsSortable(column) { return column != COLUMN.QR_CODE; }
+
+export function columnNeedsCasefold(column) {
+	return [
+		COLUMN.IDENTIFIER, 
+		COLUMN.NAME, 
+		COLUMN.DESCRIPTION
+	].includes(column);
+}
+
+export function columnIsNumeric(column) {
+	return [
+		COLUMN.USERS
+	].includes(column);
+}
diff --git a/output/main.js b/output/main.js
new file mode 100644
index 0000000..c21a72d
--- /dev/null
+++ b/output/main.js
@@ -0,0 +1,282 @@
+// Hello reader!
+// This project can be found at:
+// https://lokilocker.com/someguy/sessioncommunities.online
+
+/**
+ * This JavaScript file uses the JSDoc commenting style.
+ * Learn more: https://jsdoc.app/
+ */
+ 
+ // Nudge TypeScript plugins to type-check using JSDoc comments.
+ // @ts-check
+
+// Early prevention for bugs introduced by lazy coding.
+'use strict';
+
+// Import magic numbers and data 
+import { 
+	dom, COLUMN, COLUMN_LITERAL, COMPARISON, ATTRIBUTES,
+	columnAscendingByDefault, columnIsSortable, columnNeedsCasefold,
+	columnIsNumeric
+} from './js/constants.js';
+
+// Hidden communities for transparency.
+const filteredCommunities = {
+	tests: [
+		"2e9345+c7fb",  // TestRoom
+		"762ba9+c7fb",  // TesterRoom
+		"b4d829+c7fb",  // Test
+		"e5853a+c7fb",  // testtest
+		"fishing+8e2e", // Example group from PySOGS documentation
+		"test+118d",    // Testing 1, 2, 3
+		"test+13f6",    // Testing room
+		"test+c01b",    // Testing room
+		"test+fe93",    // 测试(Test)
+		"xyz+efca",     // XYZ Room
+	],
+
+	offensive: [
+		"60fa60+c7fb",  // "N-word" Community
+		"ab1a4d+c7fb",  // zUnsensored Group (CSAM)
+		"gore+e5e0"     // gore
+	],
+
+	// These communities should be checked regularly 
+	// in case they update their PySOGS version
+	legacy: [
+		"Ukraine+02bd"  // https://reccacon.com/view/room/Ukraine
+	]
+};
+
+// This can be achieved with `text-overflow: ellipsis` instead
+// and generated entirely server-side.
+const transformJoinURL = (join_link) => 
+	`${join_link.substring(0, 31)}...
+	
+	`.trim();
+
+function onLoad(timestamp) {
+	setLastChecked(timestamp);
+	hideBadCommunities();
+	sortTable(COLUMN.NAME);
+	createJoinLinkButtons();
+	markSortableColumns();
+}
+
+function displayQRModal(communityID) {
+	dom.qr_modal(communityID).style.display = "block";
+}
+
+function hideQRModal(communityID) {
+	dom.qr_modal(communityID).style.display = "none";
+}
+
+function createJoinLinkButtons() {
+	const join_URLs = dom.join_urls();
+	Array.from(join_URLs).forEach((td_url) => {
+		const a_href = td_url.querySelector('a'); // get first (only)  element
+		const join_link = a_href.getAttribute("href"); // get link		
+		td_url.innerHTML = transformJoinURL(join_link); // add interactive content
+	});
+}
+
+function hideBadCommunities() {
+	let numberOfHiddenCommunities = 0;
+	
+	for (const category of ['tests', 'offensive', 'legacy']) {
+		filteredCommunities[category].forEach(hideElementByID);
+		numberOfHiddenCommunities += filteredCommunities[category].length;
+	}
+
+	// Not ideal. Separate element should be allocated for content.
+	const summary = dom.td_summary();
+	summary.innerText += ` (${numberOfHiddenCommunities} hidden)`;
+}
+
+function hideElementByID(id) {
+	document.getElementById(id)?.remove();
+}
+
+/**
+ * Copies text to clipboard and shows an informative toast.
+ * @param {string} text - Text to copy to clipboard.
+ */
+function copyToClipboard(text) {
+	navigator.clipboard.writeText(text);
+
+	// Find snackbar element
+	const snackbar = dom.snackbar();
+
+	snackbar.classList.add('show')
+	
+	// After 3 seconds, hide the snackbar.
+	setTimeout(() => snackbar.classList.remove('show'), 3000);
+}
+
+/**
+ * Sets the "last checked indicator" based on a timestamp.
+ * @param {number} last_checked - Timestamp of last community list update.
+ */
+function setLastChecked(last_checked) {
+	const seconds_now = Math.floor(Date.now() / 1000); // timestamp in seconds
+	const time_passed_in_seconds = seconds_now - last_checked;
+	const time_passed_in_minutes = 
+		Math.floor(time_passed_in_seconds / 60); // time in minutes, rounded down
+	const timestamp_element = dom.td_last_checked();
+	timestamp_element.innerText = 
+		`Last checked ${time_passed_in_minutes} minutes ago.`;
+}
+
+/**
+ * Function comparing two elements.
+ *
+ * @callback comparer
+ * @param {*} fst - First value to compare.
+ * @param {*} snd - Second value to compare.
+ * @returns 1 if fst is to come first, -1 if snd is, 0 otherwise. 
+ */
+
+/**
+ * Performs a comparison on two arbitrary values. Treats "" as Infinity.
+ * @param {*} fst - First value to compare.
+ * @param {*} snd - Second value to compare.
+ * @returns 1 if fst > snd, -1 if fst < snd, 0 otherwise. 
+ */
+function compareAscending(fst, snd) {
+	// Triple equals to avoid "" == 0.
+	if (fst === "") return COMPARISON.GREATER;
+	if (snd === "") return COMPARISON.SMALLER;
+	return (fst > snd) - (fst < snd);
+}
+
+/**
+ * Performs a comparison on two arbitrary values. Treats "" as Infinity.
+ * @param {*} fst - First value to compare.
+ * @param {*} snd - Second value to compare.
+ * @returns -1 if fst > snd, 1 if fst < snd, 0 otherwise. 
+ */
+function compareDescending(fst, snd) {
+	return -compareAscending(fst, snd);
+}
+
+/**
+ * Produces a comparer dependent on a derived property of the compared elements.
+ * @param {comparer} comparer - Callback comparing derived properties.
+ * @param {Function} getProp - Callback to retrieve derived property.
+ * @returns {comparer} Function comparing elements based on derived property.
+ */
+function compareProp(comparer, getProp) {
+	return (fst, snd) => comparer(getProp(fst), getProp(snd));
+}
+
+/**
+ * Produces a comparer for table rows based on given sorting parameters.
+ * @param {number} column - Numeric ID of column to be sorted.
+ * @param {boolean} ascending - Sort ascending if true, descending otherwise.
+ * @returns {comparer}
+ */
+function makeRowComparer(column, ascending) {
+	if (!columnIsSortable(column)) {
+		throw new Error(`Column ${column} is not sortable`);
+	}
+	
+	// Callback to obtain sortable content from cell text.
+	let contentToSortable = (text) => text.trim();
+	
+	if (columnNeedsCasefold(column)) {
+		// Make certain columns sort regardless of casing.
+		contentToSortable = (text) => text.toLowerCase().trim();
+	}
+	else if (columnIsNumeric(column)) {
+		// Make certain columns sort on parsed numeric value instead of text.
+		contentToSortable = (text) => parseInt(text);
+	}
+	
+	// Construct comparer using derived property to determine sort order. 
+	const rowComparer = compareProp(
+		ascending ? compareAscending : compareDescending,
+		row => contentToSortable(row.children[column].innerText)
+	);
+	
+	return rowComparer;
+}
+
+/**
+ * @typedef {Object} SortState
+ * @property {number} column - Column ID being sorted.
+ * @property {boolean} ascending - Whether the column is sorted ascending.
+ */
+
+/**
+ * Retrieves a table's sort settings from the DOM.
+ * @param {HTMLElement} table - Table of communities being sorted.
+ * @returns {?SortState}
+ */
+function getSortState(table) {
+	if (!table.hasAttribute(ATTRIBUTES.SORTING.ACTIVE)) return null;
+	const directionState = table.getAttribute(ATTRIBUTES.SORTING.ASCENDING);
+	// This is not pretty, but the least annoying.
+	// Checking for classes would be more idiomatic.
+	const ascending = directionState.toString() === "true";
+	const columnState = table.getAttribute(ATTRIBUTES.SORTING.COLUMN);
+	const column = parseInt(columnState);
+	if (!Number.isInteger(column)) {
+		throw new Error(`Invalid column number read from table: ${columnState}`)
+	}
+	return { ascending, column };
+}
+
+/**
+ * Sets a table's sort settings using the DOM.
+ * @param {HTMLElement} table - Table of communities being sorted.
+ * @param {SortState} sortState - Sorting settings being applied.
+ */
+function setSortState(table, { ascending, column }) {
+	if (!table.hasAttribute(ATTRIBUTES.SORTING.ACTIVE)) {
+		table.setAttribute(ATTRIBUTES.SORTING.ACTIVE, true);
+	}
+	table.setAttribute(ATTRIBUTES.SORTING.ASCENDING, ascending);
+	table.setAttribute(ATTRIBUTES.SORTING.COLUMN, column);
+	// This can be used to style column headers in a consistent way, i.e. 
+	// #tbl_communities[data-sort-asc=true][sorted-by=name]::after #th_name, ...
+	table.setAttribute(ATTRIBUTES.SORTING.COLUMN_LITERAL, COLUMN_LITERAL[column]);
+}
+
+// This is best done in JS, as it would require