Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o
- Single-page, zero-build web app
- Tasks persist in `localStorage` by default, with optional GitHub sign-in sync
- GitHub OAuth uses PKCE in the browser and an HTTP-only session cookie
- Optional task descriptions render safe basic markdown for bold, italic, code, links, and line breaks
- Keyboard-friendly (more shortcuts coming, see #5)
- Light & dark themes (dark coming, see #1)
- MIT licensed
Expand Down
26 changes: 17 additions & 9 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
Expand Down Expand Up @@ -27,14 +27,22 @@ <h1>TaskForge</h1>
<p id="auth-message" class="auth-message" hidden></p>

<form id="task-form" class="task-form" aria-label="Add a new task">
<input
id="task-input"
type="text"
name="title"
placeholder="What needs doing?"
autocomplete="off"
required
/>
<div class="task-fields">
<input
id="task-input"
type="text"
name="title"
placeholder="What needs doing?"
autocomplete="off"
required
/>
<textarea
id="task-description"
name="description"
placeholder="Optional description with markdown"
rows="3"
></textarea>
</div>
<button type="submit">Add</button>
</form>

Expand Down
5 changes: 4 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const PORT = Number(process.env.PORT || 8000);
const PUBLIC_DIR = __dirname;
const PUBLIC_ROOT = `${PUBLIC_DIR}${path.sep}`;
const DATA_FILE =
process.env.TASKFORGE_DATA_FILE || path.join(__dirname, ".taskforge-data.json");
process.env.TASKFORGE_DATA_FILE ||
path.join(__dirname, ".taskforge-data.json");
const SESSION_COOKIE = "taskforge_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;

Expand All @@ -17,6 +18,7 @@ const CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
};

Expand Down Expand Up @@ -261,6 +263,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, 2000),
done: Boolean(task.done),
createdAt: Number.isFinite(task.createdAt) ? task.createdAt : Date.now(),
}))
Expand Down
19 changes: 17 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
logout,
startGitHubSignIn,
} from "./auth.js";
import { renderMarkdown } from "./markdown.mjs";
import { load, loadLocal, save } from "./storage.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");
const authStatus = document.getElementById("auth-status");
Expand Down Expand Up @@ -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 description = document.createElement("div");
description.className = "task-description";
description.replaceChildren(renderMarkdown(task.description));
content.append(description);
}

const del = document.createElement("button");
del.type = "button";
Expand All @@ -80,18 +93,20 @@ function render() {
render();
});

li.append(checkbox, label, del);
li.append(checkbox, content, del);
list.appendChild(li);
}
}

form.addEventListener("submit", async (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];
await save(tasks, user);
input.value = "";
descriptionInput.value = "";
input.focus();
render();
});
Expand Down
149 changes: 149 additions & 0 deletions src/markdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
export function parseMarkdown(value) {
return normalizeText(value)
.split(/\r?\n/)
.map((line) => parseInline(line));
}

export function renderMarkdown(value, doc = document) {
const fragment = doc.createDocumentFragment();
const lines = parseMarkdown(value);

lines.forEach((line, index) => {
if (index > 0) fragment.append(doc.createElement("br"));
for (const token of line) {
fragment.append(renderToken(token, doc));
}
});

return fragment;
}

export function isSafeLink(url) {
const normalized = normalizeText(url);
if (!normalized) return false;
try {
const parsed = new URL(normalized, "https://taskforge.local");
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}

function parseInline(value) {
const text = normalizeText(value);
const tokens = [];
let cursor = 0;

while (cursor < text.length) {
const codeEnd = startsAt(text, cursor, "`")
? text.indexOf("`", cursor + 1)
: -1;
if (codeEnd > cursor + 1) {
tokens.push({ type: "code", text: text.slice(cursor + 1, codeEnd) });
cursor = codeEnd + 1;
continue;
}

const boldEnd = startsAt(text, cursor, "**")
? text.indexOf("**", cursor + 2)
: -1;
if (boldEnd > cursor + 2) {
tokens.push({ type: "strong", text: text.slice(cursor + 2, boldEnd) });
cursor = boldEnd + 2;
continue;
}

const italicEnd =
startsAt(text, cursor, "*") && !startsAt(text, cursor, "**")
? text.indexOf("*", cursor + 1)
: -1;
if (italicEnd > cursor + 1) {
tokens.push({ type: "em", text: text.slice(cursor + 1, italicEnd) });
cursor = italicEnd + 1;
continue;
}

const link = parseLink(text, cursor);
if (link) {
tokens.push(link);
cursor = link.nextCursor;
continue;
}

const nextSpecial = findNextSpecial(text, cursor + 1);
tokens.push({
type: "text",
text: text.slice(cursor, nextSpecial === -1 ? text.length : nextSpecial),
});
cursor = nextSpecial === -1 ? text.length : nextSpecial;
}

return tokens.map(({ nextCursor, ...token }) => token);
}

function parseLink(text, cursor) {
if (!startsAt(text, cursor, "[")) return null;
const labelEnd = text.indexOf("]", cursor + 1);
if (labelEnd <= cursor + 1 || text[labelEnd + 1] !== "(") return null;
const urlEnd = findLinkUrlEnd(text, labelEnd + 2);
if (urlEnd <= labelEnd + 2) return null;

const label = text.slice(cursor + 1, labelEnd);
const url = text.slice(labelEnd + 2, urlEnd).trim();
if (!isSafeLink(url)) {
return { type: "text", text: label, nextCursor: urlEnd + 1 };
}
return { type: "link", text: label, url, nextCursor: urlEnd + 1 };
}

function findLinkUrlEnd(text, start) {
let depth = 1;
for (let index = start; index < text.length; index += 1) {
if (text[index] === "(") depth += 1;
if (text[index] === ")") depth -= 1;
if (depth === 0) return index;
}
return -1;
}

function renderToken(token, doc) {
if (token.type === "strong") {
const element = doc.createElement("strong");
element.textContent = token.text;
return element;
}
if (token.type === "em") {
const element = doc.createElement("em");
element.textContent = token.text;
return element;
}
if (token.type === "code") {
const element = doc.createElement("code");
element.textContent = token.text;
return element;
}
if (token.type === "link") {
const element = doc.createElement("a");
element.href = token.url;
element.target = "_blank";
element.rel = "noopener noreferrer";
element.textContent = token.text;
return element;
}
return doc.createTextNode(token.text);
}

function findNextSpecial(text, start) {
const indexes = ["`", "*", "["]
.map((char) => text.indexOf(char, start))
.filter((index) => index !== -1);
return indexes.length ? Math.min(...indexes) : -1;
}

function normalizeText(value) {
return typeof value === "string" ? value : "";
}

function startsAt(text, index, match) {
return text.slice(index, index + match.length) === match;
}
3 changes: 2 additions & 1 deletion src/tasks.js
Original file line number Diff line number Diff line change
@@ -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(),
};
Expand Down
44 changes: 40 additions & 4 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,8 +101,15 @@ body {
margin-bottom: 1.25rem;
}

.task-form input {
.task-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.task-form input,
.task-form textarea {
padding: 0.5rem 0.75rem;
font: inherit;
color: inherit;
Expand All @@ -111,7 +118,13 @@ body {
border-radius: var(--radius);
}

.task-form input:focus {
.task-form textarea {
min-height: 4.75rem;
resize: vertical;
}

.task-form input:focus,
.task-form textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
Expand Down Expand Up @@ -155,10 +168,33 @@ body {
}

.task-title {
display: block;
font-weight: 600;
}

.task-content {
flex: 1;
min-width: 0;
word-break: break-word;
}

.task-description {
margin-top: 0.25rem;
color: var(--muted);
font-size: 0.95rem;
}

.task-description code {
padding: 0.05rem 0.25rem;
color: var(--fg);
background: rgba(175, 184, 193, 0.2);
border-radius: 4px;
}

.task-description a {
color: var(--accent);
}

.task-delete {
appearance: none;
background: transparent;
Expand Down
Loading