diff --git a/index.html b/index.html index 7309515..96bb0bd 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,12 @@

TaskForge

autocomplete="off" required /> + diff --git a/src/app.js b/src/app.js index fe0e9c1..d485282 100644 --- a/src/app.js +++ b/src/app.js @@ -1,8 +1,10 @@ import { load, 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"); @@ -29,9 +31,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"; @@ -44,7 +57,7 @@ function render() { render(); }); - li.append(checkbox, label, del); + li.append(checkbox, content, del); list.appendChild(li); } } @@ -53,9 +66,10 @@ form.addEventListener("submit", (e) => { e.preventDefault(); const title = input.value.trim(); if (!title) return; - tasks = [createTask(title), ...tasks]; + tasks = [createTask(title, descriptionInput.value), ...tasks]; save(tasks); input.value = ""; + descriptionInput.value = ""; input.focus(); render(); }); diff --git a/src/markdown.js b/src/markdown.js new file mode 100644 index 0000000..52a9619 --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,58 @@ +const ALLOWED_TAGS = new Set(["A", "BR", "CODE", "EM", "STRONG"]); +const TEMPLATE = document.createElement("template"); + +export function renderMarkdown(markdown) { + TEMPLATE.innerHTML = toHtml(markdown); + sanitize(TEMPLATE.content); + return TEMPLATE.content.cloneNode(true); +} + +function toHtml(markdown) { + return escapeHtml(markdown) + .replace(/\r\n?/g, "\n") + .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1') + .replace(/`([^`\n]+)`/g, "$1") + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/\*([^*\n]+)\*/g, "$1") + .replace(/\n/g, "
"); +} + +function sanitize(root) { + for (const node of [...root.querySelectorAll("*")]) { + if (!ALLOWED_TAGS.has(node.tagName)) { + node.replaceWith(document.createTextNode(node.textContent)); + continue; + } + + for (const attr of [...node.attributes]) { + if (node.tagName !== "A" || attr.name !== "href") node.removeAttribute(attr.name); + } + + if (node.tagName === "A") { + const href = node.getAttribute("href") || ""; + if (!href.startsWith("http://") && !href.startsWith("https://")) { + node.replaceWith(document.createTextNode(node.textContent)); + continue; + } + node.target = "_blank"; + node.rel = "noopener noreferrer"; + } + } +} + +function escapeHtml(value) { + return value.replace(/[&<>"]/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return char; + } + }); +} 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 2fa1e18..b978e55 100644 --- a/styles.css +++ b/styles.css @@ -47,12 +47,13 @@ body { } .task-form { - display: flex; + display: grid; 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; @@ -62,12 +63,17 @@ body { border-radius: var(--radius); } -.task-form input:focus { +.task-form input:focus, +.task-form textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } +.task-form textarea { + resize: vertical; +} + .task-form button { padding: 0.5rem 1rem; font: inherit; @@ -105,11 +111,33 @@ body { color: var(--done); } -.task-title { +.task-content { flex: 1; + min-width: 0; +} + +.task-title { + display: block; + font-weight: 600; word-break: break-word; } +.task-description { + margin-top: 0.25rem; + color: var(--muted); + word-break: break-word; +} + +.task-description code { + padding: 0.1rem 0.25rem; + background: rgba(175, 184, 193, 0.2); + border-radius: 4px; +} + +.task-description a { + color: var(--accent); +} + .task-delete { appearance: none; background: transparent;