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;