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, },