diff --git a/index.html b/index.html
index b6b00fb..6a5655e 100644
--- a/index.html
+++ b/index.html
@@ -27,14 +27,22 @@
TaskForge
diff --git a/server.js b/server.js
index 695407f..86de0a0 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, 5000),
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..edff5ee 100644
--- a/src/app.js
+++ b/src/app.js
@@ -5,10 +5,12 @@ import {
startGitHubSignIn,
} from "./auth.js";
import { load, loadLocal, save } from "./storage.js";
+import { renderMarkdown } from "./markdown.js";
import { createTask, toggleTask, removeTask } from "./tasks.js";
const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
+const description = 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.append(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, description.value), ...tasks];
await save(tasks, user);
input.value = "";
+ description.value = "";
input.focus();
render();
});
diff --git a/src/markdown.js b/src/markdown.js
new file mode 100644
index 0000000..a7a8743
--- /dev/null
+++ b/src/markdown.js
@@ -0,0 +1,58 @@
+const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
+
+export function renderMarkdown(markdown) {
+ const root = document.createDocumentFragment();
+ const blocks = String(markdown || "").replace(/\r\n?/g, "\n").split(/\n{2,}/);
+
+ for (const block of blocks) {
+ const text = block.trim();
+ if (!text) continue;
+ const p = document.createElement("p");
+ appendInlineMarkdown(p, text.replace(/\n/g, " "));
+ root.append(p);
+ }
+
+ return root;
+}
+
+function appendInlineMarkdown(parent, source) {
+ const pattern = /(`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*)/g;
+ let index = 0;
+ for (const match of source.matchAll(pattern)) {
+ appendText(parent, source.slice(index, match.index));
+ if (match[2]) appendElement(parent, "code", match[2]);
+ else if (match[3] && match[4]) appendLink(parent, match[3], match[4]);
+ else if (match[5]) appendElement(parent, "strong", match[5]);
+ else if (match[6]) appendElement(parent, "em", match[6]);
+ index = match.index + match[0].length;
+ }
+ appendText(parent, source.slice(index));
+}
+
+function appendText(parent, text) {
+ if (text) parent.append(document.createTextNode(text));
+}
+
+function appendElement(parent, tagName, text) {
+ const element = document.createElement(tagName);
+ element.textContent = text;
+ parent.append(element);
+}
+
+function appendLink(parent, text, href) {
+ try {
+ const url = new URL(href, window.location.href);
+ if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
+ appendText(parent, text);
+ return;
+ }
+ const a = document.createElement("a");
+ a.href = url.href;
+ a.textContent = text;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ parent.append(a);
+ } catch {
+ appendText(parent, text);
+ }
+}
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..0bb01bb 100644
--- a/styles.css
+++ b/styles.css
@@ -209,3 +209,48 @@ body {
max-width: none;
}
}
+
+.task-fields {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.task-form textarea {
+ min-height: 5rem;
+ resize: vertical;
+ padding: 0.5rem 0.75rem;
+ font: inherit;
+ color: inherit;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
+.task-form textarea:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: -1px;
+ border-color: var(--accent);
+}
+
+.task-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.task-description {
+ margin-top: 0.25rem;
+ color: var(--muted);
+ font-size: 0.92rem;
+}
+
+.task-description p {
+ margin: 0.25rem 0 0;
+}
+
+.task-description code {
+ padding: 0.1rem 0.25rem;
+ background: rgba(175, 184, 193, 0.2);
+ border-radius: 4px;
+}
diff --git a/test/server.test.js b/test/server.test.js
index c3b9f65..70dfbfd 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -10,12 +10,18 @@ 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: " **safe** details ", 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: "**safe** details",
+ done: true,
+ createdAt: 10,
+ }],
);
});
@@ -104,7 +110,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: "Review **token** handling",
+ done: false,
+ },
+ ],
},
});
assert.equal(saved.status, 200);
@@ -114,6 +127,7 @@ test("authenticated task API persists tasks by GitHub user id", async () => {
{
id: "task-1",
title: "Ship OAuth",
+ description: "Review **token** handling",
done: false,
createdAt: loaded.body.tasks[0].createdAt,
},