From bdc5815d904f8cb66621332a658476b06cc72019 Mon Sep 17 00:00:00 2001 From: nguyenduc071912 Date: Wed, 27 May 2026 10:22:04 +0700 Subject: [PATCH] Add markdown task descriptions --- index.html | 6 ++++++ server.js | 1 + src/app.js | 18 ++++++++++++++++-- src/markdown.js | 41 +++++++++++++++++++++++++++++++++++++++++ src/tasks.js | 3 ++- styles.css | 35 ++++++++++++++++++++++++++++++----- test/server.test.js | 28 +++++++++++++++++++++++++--- 7 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 src/markdown.js 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, },