diff --git a/index.html b/index.html
index 7309515..759ad7f 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..77c29cc 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,8 +1,10 @@
import { load, 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");
@@ -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.appendChild(label);
+
+ if (task.description) {
+ const taskDescription = document.createElement("div");
+ taskDescription.className = "task-description";
+ taskDescription.appendChild(renderMarkdown(task.description));
+ content.appendChild(taskDescription);
+ }
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, description.value), ...tasks];
save(tasks);
input.value = "";
+ description.value = "";
input.focus();
render();
});
diff --git a/src/markdown.js b/src/markdown.js
new file mode 100644
index 0000000..8f955a9
--- /dev/null
+++ b/src/markdown.js
@@ -0,0 +1,84 @@
+const TOKEN_PATTERN = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
+const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
+
+export function renderMarkdown(markdown) {
+ const fragment = document.createDocumentFragment();
+ const blocks = String(markdown || "")
+ .replace(/\r\n?/g, "\n")
+ .split(/\n{2,}/)
+ .map((block) => block.trim())
+ .filter(Boolean);
+
+ for (const block of blocks) {
+ const paragraph = document.createElement("p");
+ appendInlineMarkdown(paragraph, block.replace(/\n/g, " "));
+ fragment.appendChild(paragraph);
+ }
+
+ return fragment;
+}
+
+function appendInlineMarkdown(parent, text) {
+ let index = 0;
+ for (const match of text.matchAll(TOKEN_PATTERN)) {
+ if (match.index > index) {
+ parent.append(document.createTextNode(text.slice(index, match.index)));
+ }
+ parent.append(createInlineNode(match[0]));
+ index = match.index + match[0].length;
+ }
+
+ if (index < text.length) {
+ parent.append(document.createTextNode(text.slice(index)));
+ }
+}
+
+function createInlineNode(token) {
+ if (token.startsWith("`")) {
+ const code = document.createElement("code");
+ code.textContent = token.slice(1, -1);
+ return code;
+ }
+
+ if (token.startsWith("**")) {
+ const strong = document.createElement("strong");
+ strong.textContent = token.slice(2, -2);
+ return strong;
+ }
+
+ if (token.startsWith("*")) {
+ const emphasis = document.createElement("em");
+ emphasis.textContent = token.slice(1, -1);
+ return emphasis;
+ }
+
+ const linkMatch = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
+ if (linkMatch) {
+ return createSafeLink(linkMatch[1], linkMatch[2]);
+ }
+
+ return document.createTextNode(token);
+}
+
+function createSafeLink(label, rawUrl) {
+ const anchor = document.createElement("a");
+ anchor.textContent = label;
+ anchor.target = "_blank";
+ anchor.rel = "noopener noreferrer";
+
+ const url = safeUrl(rawUrl);
+ if (url) {
+ anchor.href = url;
+ }
+
+ return anchor;
+}
+
+function safeUrl(rawUrl) {
+ try {
+ const url = new URL(rawUrl, window.location.href);
+ return SAFE_LINK_PROTOCOLS.has(url.protocol) ? url.href : "";
+ } catch {
+ return "";
+ }
+}
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..adb665e 100644
--- a/styles.css
+++ b/styles.css
@@ -47,13 +47,15 @@ body {
}
.task-form {
- display: flex;
+ display: grid;
+ grid-template-columns: 1fr auto;
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;
@@ -62,13 +64,21 @@ body {
border-radius: var(--radius);
}
-.task-form input:focus {
+.task-form textarea {
+ grid-column: 1 / -1;
+ min-height: 5rem;
+ resize: vertical;
+}
+
+.task-form input:focus,
+.task-form textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}
.task-form button {
+ align-self: start;
padding: 0.5rem 1rem;
font: inherit;
color: var(--accent-fg);
@@ -100,16 +110,48 @@ body {
border-radius: var(--radius);
}
-.task-item.done .task-title {
+.task-item.done .task-content {
text-decoration: line-through;
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.35rem;
+ color: var(--muted);
+ font-size: 0.95rem;
word-break: break-word;
}
+.task-description p {
+ margin: 0.25rem 0 0;
+}
+
+.task-description code {
+ padding: 0.1rem 0.25rem;
+ color: var(--fg);
+ background: #f6f8fa;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono",
+ monospace;
+ font-size: 0.9em;
+}
+
+.task-description a {
+ color: var(--accent);
+}
+
.task-delete {
appearance: none;
background: transparent;