diff --git a/contact.html b/contact.html
new file mode 100644
index 0000000..d7d37fc
--- /dev/null
+++ b/contact.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+ Contact | My Personal Blog
+
+
+
+
+
+ Contact
+ Use the form below to get in touch — submitting it opens your mail client with the details pre-filled.
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
index dfa2216..28aa417 100644
--- a/index.html
+++ b/index.html
@@ -12,6 +12,7 @@
My Blog
diff --git a/js/contact.js b/js/contact.js
new file mode 100644
index 0000000..a94a157
--- /dev/null
+++ b/js/contact.js
@@ -0,0 +1,72 @@
+import { buildMailtoUrl } from "./mailto.js";
+
+/** The address the contact form messages are sent to. */
+export const MAILTO_TARGET = "hello@example.com";
+
+/**
+ * Collect form field values by name.
+ * Works with a real DOM form and with the minimal fake form used in tests.
+ *
+ * @param {{querySelectorAll(selector: string): ArrayLike<{name: string, value: string}>}} form
+ * @returns {{name: string, email: string, message: string}}
+ */
+export function readFormValues(form) {
+ const values = {};
+ for (const el of form.querySelectorAll("[name]")) {
+ values[el.name] = el.value;
+ }
+ return values;
+}
+
+/**
+ * Build the mailto: URL for the contact form from the entered values.
+ *
+ * @param {{name: string, email: string, message: string}} values
+ * @param {string} [to]
+ * @returns {string}
+ */
+export function buildContactMailto(values, to = MAILTO_TARGET) {
+ const subject = `Contact form submission from ${values.name || "someone"}`;
+ const body = [
+ `Name: ${values.name || ""}`,
+ `Email: ${values.email || ""}`,
+ "",
+ "Message:",
+ values.message || "",
+ ].join("\n");
+ return buildMailtoUrl({ to, subject, body });
+}
+
+/**
+ * Handle a contact form submit: build the mailto: URL and send the
+ * visitor to it so their mail client opens pre-filled.
+ *
+ * @param {HTMLFormElement} form
+ * @param {{to?: string, navigate?: (url: string) => void}} [options]
+ * @returns {string} the mailto: URL that was navigated to
+ */
+export function handleContactSubmit(
+ form,
+ { to = MAILTO_TARGET, navigate = (url) => { window.location.href = url; } } = {}
+) {
+ const url = buildContactMailto(readFormValues(form), to);
+ navigate(url);
+ return url;
+}
+
+/**
+ * Wire the submit handler onto a contact form.
+ * @param {HTMLFormElement} form
+ */
+export function initContactForm(form) {
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ handleContactSubmit(form);
+ });
+}
+
+// Browser-only wiring — guarded so this module stays importable in Node tests.
+if (typeof window !== "undefined" && typeof document !== "undefined") {
+ const form = document.getElementById("contact-form");
+ if (form) initContactForm(form);
+}
diff --git a/js/mailto.js b/js/mailto.js
new file mode 100644
index 0000000..d353119
--- /dev/null
+++ b/js/mailto.js
@@ -0,0 +1,15 @@
+/**
+ * Build a `mailto:` URL from an address, subject, and body.
+ * Subject and body are URL-encoded so the visitor's mail client
+ * opens pre-filled with the submitted values.
+ *
+ * @param {{to?: string, subject?: string, body?: string}} options
+ * @returns {string}
+ */
+export function buildMailtoUrl({ to = "", subject = "", body = "" } = {}) {
+ const params = new URLSearchParams();
+ if (subject) params.set("subject", subject);
+ if (body) params.set("body", body);
+ const query = params.toString();
+ return query ? `mailto:${to}?${query}` : `mailto:${to}`;
+}
diff --git a/tests/contact.test.js b/tests/contact.test.js
new file mode 100644
index 0000000..d2a848d
--- /dev/null
+++ b/tests/contact.test.js
@@ -0,0 +1,109 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import {
+ MAILTO_TARGET,
+ buildContactMailto,
+ handleContactSubmit,
+ readFormValues,
+} from "../js/contact.js";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const contactHtml = readFileSync(join(root, "contact.html"), "utf8");
+const indexHtml = readFileSync(join(root, "index.html"), "utf8");
+
+/** Minimal stand-in for a DOM form (has querySelectorAll("[name]")). */
+function fakeForm(values) {
+ return {
+ querySelectorAll(selector) {
+ assert.equal(selector, "[name]");
+ return Object.entries(values).map(([name, value]) => ({ name, value }));
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Component tests: the contact page renders the three required fields
+// ---------------------------------------------------------------------------
+
+test("contact page renders a form with name, email, and message fields", () => {
+ assert.match(contactHtml, /