Skip to content

Commit 33f3c05

Browse files
committed
documentation + semantic changes markdown and preview tabs
1 parent 34ad7b0 commit 33f3c05

9 files changed

Lines changed: 237 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
### Features
88

9-
- **Todo notes Markdown preview (Phase 1)** - When **`SCRUMBOY_MARKDOWN_NOTES_ENABLED=1`** (also accepts **`true`** / **`on`** / **`yes`**), the todo dialog Notes field gains **Write** / **Preview** tabs with a sanitized Markdown preview (headings, emphasis, lists, blockquotes, inline/fenced code, horizontal rules, and safe **`http`** / **`https`** links). Todo **Title** and board card titles stay plain text; notes still persist as the raw **`todos.body`** string with no schema changes.
9+
- **Todo notes Markdown preview (Phase 1)** - When **`SCRUMBOY_MARKDOWN_NOTES_ENABLED=1`** (also accepts **`true`** / **`on`** / **`yes`**), the todo dialog Notes field gains **markdown** / **preview** tabs with a sanitized Markdown preview (headings, emphasis, lists, blockquotes, inline/fenced code, horizontal rules, and safe **`http`** / **`https`** links). Todo **Title** and board card titles stay plain text; notes still persist as the raw **`todos.body`** string with no schema changes.
1010

1111
- **Auth status flag** - **`/api/auth/status`** and bootstrap auth payloads expose **`markdownNotesEnabled`** so the UI only shows preview controls when the server has opted in.
1212

@@ -21,7 +21,7 @@
2121
### Tests
2222

2323
- **Markdown preview** - Supported subset rendering, safe vs rejected link schemes, and neutralization of raw HTML, dangerous links, and image syntax.
24-
- **Todo dialog** - Write/Preview tab behavior gated on **`markdownNotesEnabled`**.
24+
- **Todo dialog** - markdown/preview tab behavior gated on **`markdownNotesEnabled`**.
2525
- **Server/config** - **`SCRUMBOY_MARKDOWN_NOTES_ENABLED`** parsing and **`markdownNotesEnabled`** on auth status responses.
2626

2727
## [3.15.4] - 2026-05-05

FAQ.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# FAQ
2+
3+
## How do I enable Markdown in my notes?
4+
5+
Set `SCRUMBOY_MARKDOWN_NOTES_ENABLED=1` on the server (also accepts `true`, `on`, or `yes`; case-insensitive). The feature defaults to off.
6+
7+
After a restart, the todo dialog **Notes** field shows **markdown** and **preview** tabs. **markdown** is the source editor; **preview** is a sanitized rendered view. Supported syntax includes headings, emphasis, lists, blockquotes, inline and fenced code, horizontal rules (`---` on its own line with blank lines around it), and safe `http`/`https` links.
8+
9+
Notes are still stored as raw markdown in `todos.body`. Todo titles and board card titles stay plain text. The server exposes `markdownNotesEnabled` on `/api/auth/status` so the UI only shows the tabs when the feature is enabled.
10+
11+
Preview hardening: HTML in notes is not rendered; images stay as escaped text; dangerous link schemes and embedded content are stripped or neutralized.
12+
13+
For architecture, security, and source references, see [`docs/markdown.md`](docs/markdown.md).

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ Simplicity of a light Kanban, with the power of structured systems: Roles, sprin
193193

194194
- Sticky-Note Wall - per-project scratchpad of draggable sticky notes on the board (see `docs/WALL.md`).
195195

196+
- Todo notes Markdown preview - markdown/preview tabs in the todo Notes field (see `FAQ.md`, `docs/markdown.md`).
197+
196198
---
197199

198200
## Integrations & API Access

docs/markdown.md

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# Todo notes Markdown (Phase 1)
2+
3+
Phase 1 adds an **optional, client-side preview** of Markdown in the todo dialog **Notes** field. The server stores and serves notes as **plain text**; it does not parse or sanitize Markdown. Enabling the feature only exposes UI and loads browser libraries—the preview pipeline runs entirely in the SPA.
4+
5+
For operator setup, see [`FAQ.md`](../FAQ.md). This document describes architecture, configuration, and the preview security model.
6+
7+
---
8+
9+
## Scope
10+
11+
| In scope | Out of scope |
12+
|----------|----------------|
13+
| **markdown** / **preview** tabs in the Edit/New Todo dialog (`#todoBody`, `#todoBodyPreview`) | Rendering Markdown on the board, dashboard, or notifications |
14+
| Preview of `todos.body` when the user opens the **preview** tab | Server-side Markdown → HTML conversion |
15+
| Sanitized HTML in the preview pane only | WYSIWYG editing (Notes stay a `<textarea>`) |
16+
| Raw Markdown persisted on create/patch | Images, raw HTML, or non-http(s) links in preview |
17+
18+
Todo **titles** and card titles on the board are always escaped plain text (see `board-rendering` tests).
19+
20+
---
21+
22+
## Configuration
23+
24+
| Variable | Default | Enabled when |
25+
|----------|---------|--------------|
26+
| `SCRUMBOY_MARKDOWN_NOTES_ENABLED` | off (unset) | `1`, `true`, `on`, or `yes` (trimmed, case-insensitive) |
27+
28+
Parsed in `internal/config/config.go` (`markdownNotesEnabledFromEnv`), passed through `cmd/scrumboy/main.go` into the HTTP server (`ServerOpts.MarkdownNotesEnabled`), and exposed to the SPA as **`markdownNotesEnabled`** on:
29+
30+
- `GET /api/auth/status` (authenticated and anonymous status payloads in `internal/httpapi/routing_auth.go`)
31+
32+
The UI reads that flag once during auth bootstrap (`modules/router.ts``setMarkdownNotesEnabled`) and gates tab visibility in `modules/dialogs/todo.ts` (`markdownNotesPreviewEnabled()`).
33+
34+
There is **no per-project** or per-user toggle; it is instance-wide.
35+
36+
---
37+
38+
## Data flow
39+
40+
```
41+
User edits textarea (#todoBody)
42+
43+
44+
Create/Patch API ──► todos.body (raw Markdown string, unchanged)
45+
46+
▼ (preview tab only, client-side)
47+
renderMarkdownPreviewInto()
48+
49+
├─► markdown-it.render() (html: false)
50+
└─► DOMPurify + link policy + DOM cleanup
51+
52+
53+
#todoBodyPreview innerHTML (ephemeral, not saved)
54+
```
55+
56+
`modules/dialogs/todo-submit.ts` passes `body` through unchanged in create and patch payloads. Tests in `todo-submit.test.ts` assert Markdown is not converted to HTML before send.
57+
58+
---
59+
60+
## Frontend
61+
62+
### DOM (`internal/httpapi/web/index.html`)
63+
64+
- **Label row:** `Notes` label and `#todoBodyToggle` (tab list: `#todoBodyWriteTab`, `#todoBodyPreviewTab`).
65+
- **Editor:** `#todoBody` (textarea) and `#todoBodyPreview` (preview `div`) inside `.todo-notes-editor`.
66+
- Tabs are hidden when `markdownNotesEnabled` is false (`hidden` on `#todoBodyToggle`).
67+
68+
### Todo dialog (`modules/dialogs/todo.ts`)
69+
70+
- **`todoNotesMode`:** `"markdown"` | `"preview"`.
71+
- **`syncTodoNotesModeUI()`:** toggles `hidden` on textarea vs preview, `is-active` / `aria-pressed` on tabs.
72+
- **`renderTodoNotesPreview()`:** calls `renderMarkdownPreviewInto`; on vendor/render failure, shows a toast and falls back to markdown mode.
73+
- **`input` listener:** re-renders preview live while the preview tab is active.
74+
75+
### Preview module (`modules/markdown-preview.ts`)
76+
77+
Exported API:
78+
79+
- `renderMarkdownToSafeHtml(markdown: string): string`
80+
- `renderMarkdownPreviewInto(container: HTMLElement, markdown: string): void`
81+
82+
Empty or whitespace-only notes set `todo-markdown-preview--empty` and clear the container (placeholder via CSS `::before`).
83+
84+
### Styles (`styles.css`)
85+
86+
Preview typography and colors are scoped under `#todoDialog .todo-markdown-preview` (headings, lists, blockquote, code, `pre`, links, `hr`).
87+
88+
### State
89+
90+
- `current._markdownNotesEnabled` in `modules/state/state.ts`, set from auth status only (not from board payloads).
91+
92+
---
93+
94+
## Rendering pipeline
95+
96+
### 1. markdown-it
97+
98+
Loaded from `/vendor/markdown-it.min.js` (global `window.markdownit`). Pinned dependency: `markdown-it@14.1.1` in `internal/httpapi/web/package.json`, copied by `scripts/sync-vendor.mjs`.
99+
100+
Instance options (`getMarkdownRenderer()`):
101+
102+
| Option | Value | Rationale |
103+
|--------|-------|-----------|
104+
| `html` | `false` | Disables raw HTML blocks in source Markdown |
105+
| `breaks` | `true` | Single newlines become `<br>` |
106+
| `linkify` | `false` | Bare URLs are not auto-linked (explicit `[text](url)` only) |
107+
108+
**Custom rule:** `renderer.rules.image` rewrites image tokens to **escaped plain text** (`![alt](url)`) so no `<img>` is emitted.
109+
110+
### 2. DOMPurify
111+
112+
Second pass via `/vendor/purify.min.js` (`dompurify@3.4.3`):
113+
114+
- **`ALLOWED_TAGS`:** `a`, `blockquote`, `br`, `code`, `em`, `h1``h6`, `hr`, `li`, `ol`, `p`, `pre`, `strong`, `ul`
115+
- **`ALLOWED_ATTR`:** `href`, `rel`, `target` only
116+
- ARIA and `data-*` attributes disallowed
117+
118+
### 3. Post-sanitize DOM pass
119+
120+
On a detached `<template>`:
121+
122+
1. Remove any `img`, `iframe`, `object`, `embed`, `script`, `svg` that survived parsing.
123+
2. For each `<a>`:
124+
- **`isSafeLinkHref`:** allow empty-scheme-relative paths (`/`, `./`, `../`, `#`, `?`); allow `http`/`https` only for explicit schemes; reject `//`, `javascript:`, `data:`, `mailto:`, `tel:`, etc.
125+
- **External** (`http://` / `https://`): set `target="_blank"` and `rel="noopener noreferrer"`.
126+
- **Unsafe href:** replace anchor with text node (link text only).
127+
128+
---
129+
130+
## Supported Markdown (preview)
131+
132+
Verified in `modules/markdown-preview.test.ts`:
133+
134+
- ATX headings `#``######`
135+
- `**bold**`, `*italic*`
136+
- Bullet and ordered lists
137+
- Blockquotes (`>`)
138+
- Inline `` `code` `` and fenced code blocks
139+
- Thematic breaks (`---`, `***`, `___`) on their own line (CommonMark rules; blank lines often required)
140+
- `[label](https://…)` and safe same-origin relative links
141+
142+
**Not rendered as HTML in preview:**
143+
144+
- `![alt](url)` → escaped literal
145+
- Raw HTML in source → escaped in output
146+
- Dangerous or non-web link schemes → plain text
147+
- Protocol-relative URLs (`//host/...`)
148+
149+
---
150+
151+
## Security notes
152+
153+
- **XSS surface** is limited to the preview `div` in the todo dialog; content is never written back to the server as HTML.
154+
- **CSP / trust:** preview depends on vendored `markdown-it` and DOMPurify; `npm test` in `internal/httpapi/web` runs `verify-vendor.mjs` before Vitest.
155+
- **Link exfiltration:** only `http`/`https` external navigation; `noopener noreferrer` on external tabs.
156+
- **No Markdown on titles** avoids XSS or layout surprises on shared boards and SSE-driven card updates.
157+
158+
---
159+
160+
## Build, vendor, and PWA
161+
162+
| Asset | Role |
163+
|-------|------|
164+
| `/vendor/markdown-it.min.js` | Parser (eager script in `index.html`) |
165+
| `/vendor/purify.min.js` | Sanitizer (eager script in `index.html`) |
166+
| `/dist/markdown-preview.js` | Compiled module (TypeScript → `dist/`) |
167+
168+
Service worker (`sw.js`) precaches vendor scripts and `dist/markdown-preview.js` for offline-capable loads after first visit.
169+
170+
Regenerate vendor files after dependency bumps:
171+
172+
```bash
173+
cd internal/httpapi/web
174+
npm run sync:vendor
175+
npm run verify:vendor
176+
```
177+
178+
---
179+
180+
## Tests
181+
182+
| Location | Covers |
183+
|----------|--------|
184+
| `internal/config/config_markdown_enabled_test.go` | Env parsing |
185+
| `internal/httpapi/routing_auth_markdown_test.go` | `markdownNotesEnabled` on auth status |
186+
| `modules/markdown-preview.test.ts` | Supported subset, links, HTML/images neutralization |
187+
| `modules/dialogs/todo-markdown-preview.test.ts` | Dialog gating, preview vs textarea, raw body on save |
188+
| `modules/dialogs/todo-submit.test.ts` | API payloads keep raw Markdown |
189+
| `modules/views/board-rendering.test.ts` | Card titles do not render note Markdown |
190+
191+
---
192+
193+
## Key source files
194+
195+
| Area | Path |
196+
|------|------|
197+
| Env / config | `internal/config/config.go` |
198+
| Server flag | `internal/httpapi/server.go`, `cmd/scrumboy/main.go` |
199+
| Auth JSON | `internal/httpapi/routing_auth.go` |
200+
| Preview core | `internal/httpapi/web/modules/markdown-preview.ts` |
201+
| Todo UI | `internal/httpapi/web/modules/dialogs/todo.ts` |
202+
| Markup | `internal/httpapi/web/index.html` |
203+
| Vendor sync | `internal/httpapi/web/scripts/sync-vendor.mjs` |

internal/httpapi/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type Options struct {
4646
// set SCRUMBOY_WALL_ENABLED=0 to disable (see config.FromEnv semantics).
4747
WallEnabled bool
4848

49-
// MarkdownNotesEnabled gates the todo notes write/preview experience in the
49+
// MarkdownNotesEnabled gates the todo notes markdown/preview experience in the
5050
// SPA. When false, the frontend behaves exactly as before.
5151
MarkdownNotesEnabled bool
5252
}

internal/httpapi/web/dist/dialogs/todo.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { computeTodoDialogPermissions, setTodoFormPermissions, } from './todo-pe
1010
import { renderTagsChips, resetTodoTagAutocompleteBindings, setupTagAutocomplete, } from './todo-tags.js';
1111
export { getTodoFormPermissions, } from './todo-permissions.js';
1212
export { getTagsFromChips, normalizeTagName, removeTag, renderTagAutocomplete, renderTagsChips, setupTagAutocomplete, } from './todo-tags.js';
13-
let todoNotesMode = "write";
13+
let todoNotesMode = "markdown";
1414
let todoNotesPreviewBound = false;
1515
export function resolveColumnKey(raw) {
1616
const v = (raw || "").trim();
@@ -84,7 +84,7 @@ function renderTodoNotesPreview() {
8484
}
8585
catch (err) {
8686
showToast(err?.message || "Markdown preview is unavailable");
87-
todoNotesMode = "write";
87+
todoNotesMode = "markdown";
8888
syncTodoNotesModeUI();
8989
}
9090
}
@@ -94,7 +94,7 @@ function syncTodoNotesModeUI() {
9494
todoBodyToggle.hidden = !previewEnabled;
9595
}
9696
if (!previewEnabled) {
97-
todoNotesMode = "write";
97+
todoNotesMode = "markdown";
9898
}
9999
const isPreview = previewEnabled && todoNotesMode === "preview";
100100
if (todoBody) {
@@ -128,7 +128,7 @@ function bindTodoNotesPreviewControls() {
128128
todoNotesPreviewBound = true;
129129
if (todoBodyWriteTab) {
130130
todoBodyWriteTab.addEventListener("click", () => {
131-
setTodoNotesMode("write");
131+
setTodoNotesMode("markdown");
132132
});
133133
}
134134
if (todoBodyPreviewTab) {
@@ -378,7 +378,7 @@ export async function openTodoDialog(opts) {
378378
shareTodoBtn.style.display = "";
379379
setDates(todo.createdAt, todo.updatedAt);
380380
}
381-
setTodoNotesMode("write");
381+
setTodoNotesMode("markdown");
382382
const tagInputEl = document.getElementById("todoTags");
383383
if (tagInputEl) {
384384
tagInputEl.replaceWith(tagInputEl.cloneNode(true));

internal/httpapi/web/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@
131131
<div class="field__label-row">
132132
<label class="field__label" for="todoBody">Notes</label>
133133
<div class="todo-notes-tabs" id="todoBodyToggle" hidden role="tablist" aria-label="Notes editor mode">
134-
<button class="todo-notes-tab is-active" type="button" id="todoBodyWriteTab" role="tab" aria-pressed="true">write</button>
135-
<button class="todo-notes-tab" type="button" id="todoBodyPreviewTab" role="tab" aria-pressed="false">markdown</button>
134+
<button class="todo-notes-tab is-active" type="button" id="todoBodyWriteTab" role="tab" aria-pressed="true">markdown</button>
135+
<button class="todo-notes-tab" type="button" id="todoBodyPreviewTab" role="tab" aria-pressed="false">preview</button>
136136
</div>
137137
</div>
138138
<div class="todo-notes-editor">

internal/httpapi/web/modules/dialogs/todo-markdown-preview.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ describe("todo markdown preview", () => {
145145
expect((document.getElementById("todoBodyPreview") as HTMLElement).hidden).toBe(true);
146146
});
147147

148-
it("shows the write/preview toggle and empty preview state when enabled", async () => {
148+
it("shows the markdown/preview toggle and empty preview state when enabled", async () => {
149149
mockTodoModule(true);
150150
const { openTodoDialog } = await import("./todo.js");
151151

@@ -184,7 +184,7 @@ describe("todo markdown preview", () => {
184184
const title = document.getElementById("todoTitle") as HTMLInputElement;
185185
const body = document.getElementById("todoBody") as HTMLTextAreaElement;
186186
const previewTab = document.getElementById("todoBodyPreviewTab") as HTMLButtonElement;
187-
const writeTab = document.getElementById("todoBodyWriteTab") as HTMLButtonElement;
187+
const markdownTab = document.getElementById("todoBodyWriteTab") as HTMLButtonElement;
188188
const preview = document.getElementById("todoBodyPreview") as HTMLElement;
189189

190190
expect(title.value).toBe(rawTitle);
@@ -195,7 +195,7 @@ describe("todo markdown preview", () => {
195195
expect(preview.innerHTML).toContain("<h1>Heading</h1>");
196196
expect(preview.innerHTML).toContain("<strong>bold</strong>");
197197

198-
writeTab.click();
198+
markdownTab.click();
199199
expect(body.hidden).toBe(false);
200200
expect(body.value).toBe(rawBody);
201201
});

internal/httpapi/web/modules/dialogs/todo.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ export {
5151
setupTagAutocomplete,
5252
} from './todo-tags.js';
5353

54-
type TodoNotesMode = "write" | "preview";
54+
type TodoNotesMode = "markdown" | "preview";
5555

56-
let todoNotesMode: TodoNotesMode = "write";
56+
let todoNotesMode: TodoNotesMode = "markdown";
5757
let todoNotesPreviewBound = false;
5858

5959
export function resolveColumnKey(raw: string | undefined | null): string {
@@ -138,7 +138,7 @@ function renderTodoNotesPreview(): void {
138138
);
139139
} catch (err: any) {
140140
showToast(err?.message || "Markdown preview is unavailable");
141-
todoNotesMode = "write";
141+
todoNotesMode = "markdown";
142142
syncTodoNotesModeUI();
143143
}
144144
}
@@ -149,7 +149,7 @@ function syncTodoNotesModeUI(): void {
149149
(todoBodyToggle as HTMLElement).hidden = !previewEnabled;
150150
}
151151
if (!previewEnabled) {
152-
todoNotesMode = "write";
152+
todoNotesMode = "markdown";
153153
}
154154

155155
const isPreview = previewEnabled && todoNotesMode === "preview";
@@ -187,7 +187,7 @@ function bindTodoNotesPreviewControls(): void {
187187

188188
if (todoBodyWriteTab) {
189189
(todoBodyWriteTab as HTMLButtonElement).addEventListener("click", () => {
190-
setTodoNotesMode("write");
190+
setTodoNotesMode("markdown");
191191
});
192192
}
193193
if (todoBodyPreviewTab) {
@@ -441,7 +441,7 @@ export async function openTodoDialog(opts: {
441441
if (shareTodoBtn) (shareTodoBtn as HTMLElement).style.display = "";
442442
setDates(todo.createdAt, todo.updatedAt);
443443
}
444-
setTodoNotesMode("write");
444+
setTodoNotesMode("markdown");
445445

446446
const tagInputEl = document.getElementById("todoTags") as HTMLInputElement | null;
447447
if (tagInputEl) {

0 commit comments

Comments
 (0)