diff --git a/index.html b/index.html
index b6b00fb..ae6a71f 100644
--- a/index.html
+++ b/index.html
@@ -35,6 +35,12 @@
TaskForge
autocomplete="off"
required
/>
+
diff --git a/server.js b/server.js
index 695407f..edfc776 100644
--- a/server.js
+++ b/server.js
@@ -261,6 +261,7 @@ function sanitizeTasks(tasks) {
.map((task) => ({
id: normalizeString(task.id).slice(0, 128),
title: normalizeString(task.title).slice(0, 500),
+ description: normalizeString(task.description).slice(0, 2000),
done: Boolean(task.done),
createdAt: Number.isFinite(task.createdAt) ? task.createdAt : Date.now(),
}))
diff --git a/src/app.js b/src/app.js
index 46dd724..ceec487 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,9 +6,11 @@ import {
} from "./auth.js";
import { load, loadLocal, save } from "./storage.js";
import { createTask, toggleTask, removeTask } from "./tasks.js";
+import { renderMarkdown } from "./markdown.js";
const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
+const descriptionInput = document.getElementById("task-description");
const list = document.getElementById("task-list");
const emptyState = document.getElementById("empty-state");
const authStatus = document.getElementById("auth-status");
@@ -65,9 +67,20 @@ function render() {
render();
});
+ const content = document.createElement("div");
+ content.className = "task-content";
+
const label = document.createElement("span");
label.className = "task-title";
label.textContent = task.title;
+ content.append(label);
+
+ if (task.description) {
+ const description = document.createElement("div");
+ description.className = "task-description";
+ description.innerHTML = renderMarkdown(task.description);
+ content.append(description);
+ }
const del = document.createElement("button");
del.type = "button";
@@ -80,7 +93,7 @@ function render() {
render();
});
- li.append(checkbox, label, del);
+ li.append(checkbox, content, del);
list.appendChild(li);
}
}
@@ -89,9 +102,10 @@ form.addEventListener("submit", async (e) => {
e.preventDefault();
const title = input.value.trim();
if (!title) return;
- tasks = [createTask(title), ...tasks];
+ tasks = [createTask(title, descriptionInput.value), ...tasks];
await save(tasks, user);
input.value = "";
+ descriptionInput.value = "";
input.focus();
render();
});
diff --git a/src/markdown.js b/src/markdown.js
new file mode 100644
index 0000000..3f7d23b
--- /dev/null
+++ b/src/markdown.js
@@ -0,0 +1,41 @@
+const ALLOWED_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
+
+export function renderMarkdown(markdown) {
+ const escaped = escapeHtml(markdown || "");
+ const withCode = escaped.replace(/`([^`]+)`/g, "$1");
+ const withLinks = withCode.replace(
+ /\[([^\]]+)\]\(([^)\s]+)\)/g,
+ (_match, label, rawUrl) => {
+ const url = safeUrl(rawUrl);
+ if (!url) return label;
+ return `${label}`;
+ },
+ );
+ const withBold = withLinks.replace(/\*\*([^*]+)\*\*/g, "$1");
+ const withItalic = withBold.replace(/\*([^*]+)\*/g, "$1");
+
+ return withItalic.replace(/\r?\n/g, "
");
+}
+
+function safeUrl(rawUrl) {
+ try {
+ const url = new URL(rawUrl, window.location.origin);
+ if (!ALLOWED_LINK_PROTOCOLS.has(url.protocol)) return "";
+ return escapeAttribute(url.href);
+ } catch {
+ return "";
+ }
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function escapeAttribute(value) {
+ return String(value).replaceAll('"', "%22");
+}
diff --git a/src/tasks.js b/src/tasks.js
index a54af4a..83a521d 100644
--- a/src/tasks.js
+++ b/src/tasks.js
@@ -1,7 +1,8 @@
-export function createTask(title) {
+export function createTask(title, description = "") {
return {
id: cryptoRandomId(),
title: title.trim(),
+ description: description.trim(),
done: false,
createdAt: Date.now(),
};
diff --git a/styles.css b/styles.css
index a6c7afc..d49a580 100644
--- a/styles.css
+++ b/styles.css
@@ -96,22 +96,25 @@ body {
}
.task-form {
- display: flex;
+ display: grid;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
-.task-form input {
- flex: 1;
+.task-form input,
+.task-form textarea {
+ width: 100%;
padding: 0.5rem 0.75rem;
font: inherit;
color: inherit;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
+ resize: vertical;
}
-.task-form input:focus {
+.task-form input:focus,
+.task-form textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
@@ -149,16 +152,38 @@ body {
border-radius: var(--radius);
}
+.task-content {
+ flex: 1;
+ min-width: 0;
+}
+
.task-item.done .task-title {
text-decoration: line-through;
color: var(--done);
}
.task-title {
- flex: 1;
word-break: break-word;
}
+.task-description {
+ margin-top: 0.25rem;
+ color: var(--muted);
+ font-size: 0.9rem;
+ overflow-wrap: anywhere;
+}
+
+.task-description code {
+ padding: 0.1rem 0.25rem;
+ color: var(--fg);
+ background: rgba(128, 128, 128, 0.15);
+ border-radius: 3px;
+}
+
+.task-description a {
+ color: var(--accent);
+}
+
.task-delete {
appearance: none;
background: transparent;
diff --git a/test/server.test.js b/test/server.test.js
index c3b9f65..8d5f7b4 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -10,12 +10,26 @@ const { SESSION_COOKIE, createServer, sanitizeTasks } = require("../server.js");
test("sanitizeTasks drops malformed rows and trims persisted fields", () => {
assert.deepEqual(
sanitizeTasks([
- { id: " a ", title: " Write tests ", done: 1, createdAt: 10 },
+ {
+ id: " a ",
+ title: " Write tests ",
+ description: " **with markdown** ",
+ done: 1,
+ createdAt: 10,
+ },
{ id: "", title: "missing id" },
{ id: "missing-title", title: "" },
null,
]),
- [{ id: "a", title: "Write tests", done: true, createdAt: 10 }],
+ [
+ {
+ id: "a",
+ title: "Write tests",
+ description: "**with markdown**",
+ done: true,
+ createdAt: 10,
+ },
+ ],
);
});
@@ -104,7 +118,14 @@ test("authenticated task API persists tasks by GitHub user id", async () => {
method: "PUT",
cookie,
body: {
- tasks: [{ id: "task-1", title: "Ship OAuth", done: false }],
+ tasks: [
+ {
+ id: "task-1",
+ title: "Ship OAuth",
+ description: "Document **login** flow",
+ done: false,
+ },
+ ],
},
});
assert.equal(saved.status, 200);
@@ -114,6 +135,7 @@ test("authenticated task API persists tasks by GitHub user id", async () => {
{
id: "task-1",
title: "Ship OAuth",
+ description: "Document **login** flow",
done: false,
createdAt: loaded.body.tasks[0].createdAt,
},