diff --git a/README.md b/README.md
index f1eba62..5ff4d7c 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o
- Single-page, zero-build web app
- Tasks persist in `localStorage` by default, with optional GitHub sign-in sync
- GitHub OAuth uses PKCE in the browser and an HTTP-only session cookie
+- Optional task descriptions render safe basic markdown for bold, italic, code, links, and line breaks
- Keyboard-friendly (more shortcuts coming, see #5)
- Light & dark themes (dark coming, see #1)
- MIT licensed
diff --git a/index.html b/index.html
index b6b00fb..99de0a2 100644
--- a/index.html
+++ b/index.html
@@ -1,4 +1,4 @@
-
+
@@ -27,14 +27,22 @@ TaskForge
diff --git a/server.js b/server.js
index 695407f..151ae03 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,8 @@ const PORT = Number(process.env.PORT || 8000);
const PUBLIC_DIR = __dirname;
const PUBLIC_ROOT = `${PUBLIC_DIR}${path.sep}`;
const DATA_FILE =
- process.env.TASKFORGE_DATA_FILE || path.join(__dirname, ".taskforge-data.json");
+ process.env.TASKFORGE_DATA_FILE ||
+ path.join(__dirname, ".taskforge-data.json");
const SESSION_COOKIE = "taskforge_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
@@ -17,6 +18,7 @@ const CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
};
@@ -261,6 +263,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..21fd800 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4,11 +4,13 @@ import {
logout,
startGitHubSignIn,
} from "./auth.js";
+import { renderMarkdown } from "./markdown.mjs";
import { load, loadLocal, save } from "./storage.js";
import { createTask, toggleTask, removeTask } from "./tasks.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.replaceChildren(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);
}
}
@@ -88,10 +101,12 @@ function render() {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const title = input.value.trim();
+ const description = descriptionInput.value.trim();
if (!title) return;
- tasks = [createTask(title), ...tasks];
+ tasks = [createTask(title, description), ...tasks];
await save(tasks, user);
input.value = "";
+ descriptionInput.value = "";
input.focus();
render();
});
diff --git a/src/markdown.mjs b/src/markdown.mjs
new file mode 100644
index 0000000..ca8d7f2
--- /dev/null
+++ b/src/markdown.mjs
@@ -0,0 +1,149 @@
+export function parseMarkdown(value) {
+ return normalizeText(value)
+ .split(/\r?\n/)
+ .map((line) => parseInline(line));
+}
+
+export function renderMarkdown(value, doc = document) {
+ const fragment = doc.createDocumentFragment();
+ const lines = parseMarkdown(value);
+
+ lines.forEach((line, index) => {
+ if (index > 0) fragment.append(doc.createElement("br"));
+ for (const token of line) {
+ fragment.append(renderToken(token, doc));
+ }
+ });
+
+ return fragment;
+}
+
+export function isSafeLink(url) {
+ const normalized = normalizeText(url);
+ if (!normalized) return false;
+ try {
+ const parsed = new URL(normalized, "https://taskforge.local");
+ return ["http:", "https:", "mailto:"].includes(parsed.protocol);
+ } catch {
+ return false;
+ }
+}
+
+function parseInline(value) {
+ const text = normalizeText(value);
+ const tokens = [];
+ let cursor = 0;
+
+ while (cursor < text.length) {
+ const codeEnd = startsAt(text, cursor, "`")
+ ? text.indexOf("`", cursor + 1)
+ : -1;
+ if (codeEnd > cursor + 1) {
+ tokens.push({ type: "code", text: text.slice(cursor + 1, codeEnd) });
+ cursor = codeEnd + 1;
+ continue;
+ }
+
+ const boldEnd = startsAt(text, cursor, "**")
+ ? text.indexOf("**", cursor + 2)
+ : -1;
+ if (boldEnd > cursor + 2) {
+ tokens.push({ type: "strong", text: text.slice(cursor + 2, boldEnd) });
+ cursor = boldEnd + 2;
+ continue;
+ }
+
+ const italicEnd =
+ startsAt(text, cursor, "*") && !startsAt(text, cursor, "**")
+ ? text.indexOf("*", cursor + 1)
+ : -1;
+ if (italicEnd > cursor + 1) {
+ tokens.push({ type: "em", text: text.slice(cursor + 1, italicEnd) });
+ cursor = italicEnd + 1;
+ continue;
+ }
+
+ const link = parseLink(text, cursor);
+ if (link) {
+ tokens.push(link);
+ cursor = link.nextCursor;
+ continue;
+ }
+
+ const nextSpecial = findNextSpecial(text, cursor + 1);
+ tokens.push({
+ type: "text",
+ text: text.slice(cursor, nextSpecial === -1 ? text.length : nextSpecial),
+ });
+ cursor = nextSpecial === -1 ? text.length : nextSpecial;
+ }
+
+ return tokens.map(({ nextCursor, ...token }) => token);
+}
+
+function parseLink(text, cursor) {
+ if (!startsAt(text, cursor, "[")) return null;
+ const labelEnd = text.indexOf("]", cursor + 1);
+ if (labelEnd <= cursor + 1 || text[labelEnd + 1] !== "(") return null;
+ const urlEnd = findLinkUrlEnd(text, labelEnd + 2);
+ if (urlEnd <= labelEnd + 2) return null;
+
+ const label = text.slice(cursor + 1, labelEnd);
+ const url = text.slice(labelEnd + 2, urlEnd).trim();
+ if (!isSafeLink(url)) {
+ return { type: "text", text: label, nextCursor: urlEnd + 1 };
+ }
+ return { type: "link", text: label, url, nextCursor: urlEnd + 1 };
+}
+
+function findLinkUrlEnd(text, start) {
+ let depth = 1;
+ for (let index = start; index < text.length; index += 1) {
+ if (text[index] === "(") depth += 1;
+ if (text[index] === ")") depth -= 1;
+ if (depth === 0) return index;
+ }
+ return -1;
+}
+
+function renderToken(token, doc) {
+ if (token.type === "strong") {
+ const element = doc.createElement("strong");
+ element.textContent = token.text;
+ return element;
+ }
+ if (token.type === "em") {
+ const element = doc.createElement("em");
+ element.textContent = token.text;
+ return element;
+ }
+ if (token.type === "code") {
+ const element = doc.createElement("code");
+ element.textContent = token.text;
+ return element;
+ }
+ if (token.type === "link") {
+ const element = doc.createElement("a");
+ element.href = token.url;
+ element.target = "_blank";
+ element.rel = "noopener noreferrer";
+ element.textContent = token.text;
+ return element;
+ }
+ return doc.createTextNode(token.text);
+}
+
+function findNextSpecial(text, start) {
+ const indexes = ["`", "*", "["]
+ .map((char) => text.indexOf(char, start))
+ .filter((index) => index !== -1);
+ return indexes.length ? Math.min(...indexes) : -1;
+}
+
+function normalizeText(value) {
+ return typeof value === "string" ? value : "";
+}
+
+function startsAt(text, index, match) {
+ return text.slice(index, index + match.length) === match;
+}
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..2955b98 100644
--- a/styles.css
+++ b/styles.css
@@ -15,8 +15,8 @@
body {
margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
- sans-serif;
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
@@ -101,8 +101,15 @@ body {
margin-bottom: 1.25rem;
}
-.task-form input {
+.task-fields {
flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.task-form input,
+.task-form textarea {
padding: 0.5rem 0.75rem;
font: inherit;
color: inherit;
@@ -111,7 +118,13 @@ body {
border-radius: var(--radius);
}
-.task-form input:focus {
+.task-form textarea {
+ min-height: 4.75rem;
+ resize: vertical;
+}
+
+.task-form input:focus,
+.task-form textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
@@ -155,10 +168,33 @@ body {
}
.task-title {
+ display: block;
+ font-weight: 600;
+}
+
+.task-content {
flex: 1;
+ min-width: 0;
word-break: break-word;
}
+.task-description {
+ margin-top: 0.25rem;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+.task-description code {
+ padding: 0.05rem 0.25rem;
+ color: var(--fg);
+ background: rgba(175, 184, 193, 0.2);
+ border-radius: 4px;
+}
+
+.task-description a {
+ color: var(--accent);
+}
+
.task-delete {
appearance: none;
background: transparent;
diff --git a/test/markdown.test.mjs b/test/markdown.test.mjs
new file mode 100644
index 0000000..7e8869d
--- /dev/null
+++ b/test/markdown.test.mjs
@@ -0,0 +1,45 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { isSafeLink, parseMarkdown } from "../src/markdown.mjs";
+
+test("parseMarkdown recognizes basic inline markdown", () => {
+ assert.deepEqual(parseMarkdown("Ship **bold** and *italic* with `code`"), [
+ [
+ { type: "text", text: "Ship " },
+ { type: "strong", text: "bold" },
+ { type: "text", text: " and " },
+ { type: "em", text: "italic" },
+ { type: "text", text: " with " },
+ { type: "code", text: "code" },
+ ],
+ ]);
+});
+
+test("parseMarkdown keeps multi-line descriptions", () => {
+ assert.deepEqual(parseMarkdown("One\nTwo"), [
+ [{ type: "text", text: "One" }],
+ [{ type: "text", text: "Two" }],
+ ]);
+});
+
+test("parseMarkdown accepts safe links and drops unsafe hrefs", () => {
+ assert.deepEqual(
+ parseMarkdown("[Docs](https://example.com) [Bad](javascript:alert(1))"),
+ [
+ [
+ { type: "link", text: "Docs", url: "https://example.com" },
+ { type: "text", text: " " },
+ { type: "text", text: "Bad" },
+ ],
+ ],
+ );
+});
+
+test("isSafeLink allows web and mail links only", () => {
+ assert.equal(isSafeLink("https://example.com"), true);
+ assert.equal(isSafeLink("http://example.com"), true);
+ assert.equal(isSafeLink("mailto:hello@example.com"), true);
+ assert.equal(isSafeLink("javascript:alert(1)"), false);
+ assert.equal(isSafeLink("data:text/html,hello"), false);
+});
diff --git a/test/server.test.js b/test/server.test.js
index c3b9f65..cfa5a31 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -15,7 +15,15 @@ test("sanitizeTasks drops malformed rows and trims persisted fields", () => {
{ id: "missing-title", title: "" },
null,
]),
- [{ id: "a", title: "Write tests", done: true, createdAt: 10 }],
+ [
+ {
+ id: "a",
+ title: "Write tests",
+ description: "",
+ done: true,
+ createdAt: 10,
+ },
+ ],
);
});
@@ -114,6 +122,7 @@ test("authenticated task API persists tasks by GitHub user id", async () => {
{
id: "task-1",
title: "Ship OAuth",
+ description: "",
done: false,
createdAt: loaded.body.tasks[0].createdAt,
},