diff --git a/README.md b/README.md
index 04e670c..c6eab48 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o
## Features
+- Optional task descriptions with sanitized basic Markdown rendering
- Single-page, zero-build static web app
- Tasks persist in `localStorage` — no account, no server, no tracking
- Keyboard-friendly (more shortcuts coming, see #5)
diff --git a/eslint.config.cjs b/eslint.config.cjs
new file mode 100644
index 0000000..2c3fffb
--- /dev/null
+++ b/eslint.config.cjs
@@ -0,0 +1,15 @@
+module.exports = [
+ {
+ files: ["src/**/*.js"],
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: "module",
+ globals: {
+ crypto: "readonly",
+ document: "readonly",
+ URL: "readonly",
+ window: "readonly",
+ },
+ },
+ },
+];
diff --git a/index.html b/index.html
index 7309515..7c1081e 100644
--- a/index.html
+++ b/index.html
@@ -1,4 +1,4 @@
-
+
@@ -14,14 +14,22 @@ TaskForge
diff --git a/src/app.js b/src/app.js
index fe0e9c1..5201ac1 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 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);
}
}
@@ -52,10 +65,12 @@ function render() {
form.addEventListener("submit", (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];
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..12d69b2
--- /dev/null
+++ b/src/markdown.js
@@ -0,0 +1,85 @@
+const INLINE_PATTERN =
+ /(\[([^\]\n]+)\]\(([^)\s]+)\)|`([^`\n]+)`|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*)/g;
+
+export function renderMarkdown(markdown) {
+ const fragment = document.createDocumentFragment();
+ const blocks = markdown
+ .trim()
+ .split(/\n{2,}/)
+ .map((block) => block.trim())
+ .filter(Boolean);
+
+ for (const block of blocks) {
+ const paragraph = document.createElement("p");
+ renderInlineMarkdown(block, paragraph);
+ fragment.append(paragraph);
+ }
+
+ return fragment;
+}
+
+function renderInlineMarkdown(text, parent) {
+ let lastIndex = 0;
+
+ for (const match of text.matchAll(INLINE_PATTERN)) {
+ appendTextWithLineBreaks(parent, text.slice(lastIndex, match.index));
+ parent.append(createInlineNode(match));
+ lastIndex = match.index + match[0].length;
+ }
+
+ appendTextWithLineBreaks(parent, text.slice(lastIndex));
+}
+
+function createInlineNode(match) {
+ if (match[2] && match[3]) {
+ return createLink(match[2], match[3]);
+ }
+ if (match[4]) {
+ return createElementWithText("code", match[4]);
+ }
+ if (match[5]) {
+ return createElementWithText("strong", match[5]);
+ }
+ return createElementWithText("em", match[6]);
+}
+
+function createLink(label, href) {
+ const safeUrl = toSafeUrl(href);
+ if (!safeUrl) {
+ return document.createTextNode(label);
+ }
+
+ const link = createElementWithText("a", label);
+ link.href = safeUrl;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ return link;
+}
+
+function toSafeUrl(href) {
+ try {
+ const url = new URL(href, window.location.href);
+ return ["http:", "https:", "mailto:"].includes(url.protocol)
+ ? url.href
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function createElementWithText(tagName, text) {
+ const element = document.createElement(tagName);
+ element.textContent = text;
+ return element;
+}
+
+function appendTextWithLineBreaks(parent, text) {
+ const lines = text.split("\n");
+
+ lines.forEach((line, index) => {
+ if (index > 0) {
+ parent.append(document.createElement("br"));
+ }
+ parent.append(document.createTextNode(line));
+ });
+}
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..8cd7e9f 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;
@@ -47,12 +47,21 @@ body {
}
.task-form {
- display: flex;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: start;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
-.task-form input {
+.task-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.task-form input,
+.task-form textarea {
flex: 1;
padding: 0.5rem 0.75rem;
font: inherit;
@@ -62,7 +71,12 @@ body {
border-radius: var(--radius);
}
-.task-form input:focus {
+.task-form textarea {
+ resize: vertical;
+}
+
+.task-form input:focus,
+.task-form textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
@@ -106,10 +120,38 @@ body {
}
.task-title {
+ display: block;
flex: 1;
word-break: break-word;
}
+.task-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.task-description {
+ margin-top: 0.25rem;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+.task-description p {
+ margin: 0.25rem 0 0;
+}
+
+.task-description code {
+ padding: 0.1rem 0.25rem;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--fg);
+ font-size: 0.9em;
+}
+
+.task-description a {
+ color: var(--accent);
+}
+
.task-delete {
appearance: none;
background: transparent;