|
| 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** (``) 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 | +- `` → 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` | |
0 commit comments