From be264b9f1137833f6a9aa7854f2f2938e9537ff6 Mon Sep 17 00:00:00 2001 From: jerryjumol-stack Date: Wed, 27 May 2026 06:52:35 +0100 Subject: [PATCH] feat: render sanitized markdown descriptions --- index.html | 6 +++++ server.js | 1 + src/app.js | 18 +++++++++++++-- src/markdown.js | 53 +++++++++++++++++++++++++++++++++++++++++++++ src/tasks.js | 3 ++- styles.css | 39 +++++++++++++++++++++++++++++++-- test/server.test.js | 19 ++++++++++++++-- 7 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 src/markdown.js diff --git a/index.html b/index.html index b6b00fb..b57e5bb 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..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..4dde7c0 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 descriptionEl = document.createElement("div"); + descriptionEl.className = "task-description"; + descriptionEl.innerHTML = renderMarkdown(task.description); + content.append(descriptionEl); + } 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..f6810d7 --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,53 @@ +const LINK_PATTERN = /\[([^\]]+)]\(([^)\s]+)\)/g; +const CODE_PATTERN = /`([^`]+)`/g; +const BOLD_PATTERN = /\*\*([^*]+)\*\*/g; +const ITALIC_PATTERN = /(^|[^*])\*([^*\n]+)\*/g; + +export function renderMarkdown(markdown) { + const lines = markdown + .trim() + .split(/\r?\n/) + .map((line) => renderInline(line)); + + return lines.join("
"); +} + +function renderInline(line) { + return escapeHtml(line) + .replace(CODE_PATTERN, "$1") + .replace(BOLD_PATTERN, "$1") + .replace(ITALIC_PATTERN, "$1$2") + .replace(LINK_PATTERN, renderLink); +} + +function renderLink(match, label, href) { + const safeHref = safeUrl(href); + if (!safeHref) return label; + + return `${label}`; +} + +function safeUrl(href) { + try { + const url = new URL(href, window.location.href); + if (!["http:", "https:", "mailto:"].includes(url.protocol)) { + return ""; + } + return escapeAttribute(url.href); + } catch { + return ""; + } +} + +function escapeHtml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeAttribute(value) { + return value.replace(/"/g, """); +} 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..838cbb0 100644 --- a/styles.css +++ b/styles.css @@ -97,11 +97,13 @@ body { .task-form { display: flex; + flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem; } -.task-form input { +.task-form input, +.task-form textarea { flex: 1; padding: 0.5rem 0.75rem; font: inherit; @@ -111,7 +113,13 @@ body { border-radius: var(--radius); } -.task-form input:focus { +.task-form textarea { + min-height: 5.5rem; + resize: vertical; +} + +.task-form input:focus, +.task-form textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); @@ -155,10 +163,37 @@ body { } .task-title { + display: block; + font-weight: 600; + word-break: break-word; +} + +.task-content { flex: 1; + min-width: 0; +} + +.task-description { + margin-top: 0.25rem; + color: var(--muted); + font-size: 0.93rem; word-break: break-word; } +.task-description p { + margin: 0; +} + +.task-description a { + color: var(--accent); +} + +.task-description code { + padding: 0.08rem 0.25rem; + background: rgba(175, 184, 193, 0.2); + border-radius: 4px; +} + .task-delete { appearance: none; background: transparent; diff --git a/test/server.test.js b/test/server.test.js index c3b9f65..febb8d0 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: " **Add assertions** ", + 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: "**Add assertions**", + done: true, + createdAt: 10, + }, + ], ); }); @@ -114,6 +128,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, },