beads-ui 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGES.md +14 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +103 -0
  4. package/app/data/providers.js +7 -138
  5. package/app/data/sort.js +47 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +128 -0
  8. package/app/data/subscriptions-store.js +227 -0
  9. package/app/main.js +346 -66
  10. package/app/protocol.js +23 -17
  11. package/app/protocol.md +18 -15
  12. package/app/router.js +3 -0
  13. package/app/state.js +2 -0
  14. package/app/styles.css +222 -197
  15. package/app/utils/issue-id-renderer.js +2 -1
  16. package/app/utils/issue-id.js +1 -0
  17. package/app/utils/issue-type.js +2 -0
  18. package/app/utils/issue-url.js +1 -0
  19. package/app/utils/markdown.js +13 -198
  20. package/app/utils/priority-badge.js +1 -2
  21. package/app/utils/status-badge.js +1 -1
  22. package/app/utils/status.js +2 -0
  23. package/app/utils/toast.js +1 -1
  24. package/app/utils/type-badge.js +1 -3
  25. package/app/views/board.js +172 -148
  26. package/app/views/detail.js +79 -66
  27. package/app/views/epics.js +127 -74
  28. package/app/views/issue-dialog.js +9 -15
  29. package/app/views/issue-row.js +2 -3
  30. package/app/views/list.js +105 -104
  31. package/app/views/nav.js +1 -0
  32. package/app/views/new-issue-dialog.js +30 -34
  33. package/app/ws.js +10 -10
  34. package/bin/bdui.js +1 -1
  35. package/docs/adr/001-push-only-lists.md +134 -0
  36. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  37. package/docs/architecture.md +34 -84
  38. package/docs/data-exchange-subscription-plan.md +198 -0
  39. package/docs/db-watching.md +2 -1
  40. package/docs/migration-v2.md +54 -0
  41. package/docs/protocol/issues-push-v2.md +179 -0
  42. package/docs/subscription-issue-store.md +112 -0
  43. package/package.json +5 -4
  44. package/server/app.js +2 -0
  45. package/server/bd.js +4 -2
  46. package/server/cli/commands.js +5 -2
  47. package/server/cli/daemon.js +19 -5
  48. package/server/cli/index.js +2 -2
  49. package/server/cli/open.js +3 -0
  50. package/server/cli/usage.js +2 -1
  51. package/server/config.js +13 -6
  52. package/server/db.js +3 -1
  53. package/server/index.js +9 -5
  54. package/server/list-adapters.js +224 -0
  55. package/server/subscriptions.js +289 -0
  56. package/server/validators.js +113 -0
  57. package/server/watcher.js +8 -8
  58. package/server/ws.js +457 -229
@@ -1,201 +1,16 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
3
+ import { marked } from 'marked';
4
+
1
5
  /**
2
- * Minimal, safe Markdown renderer that builds a DOM fragment without innerHTML.
3
- * Supports headings, paragraphs, lists, code blocks, inline code, and links.
4
- * This is intentionally conservative to avoid XSS.
5
- * @function renderMarkdown
6
- * @param {string} src Markdown source text
7
- * @returns {DocumentFragment}
6
+ * Render Markdown safely as HTML using marked and DOMPurify.
7
+ * Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
8
+ * embedded directly in templates.
9
+ *
10
+ * @param {string} markdown - Markdown source text
8
11
  */
9
- export function renderMarkdown(src) {
10
- /** @type {DocumentFragment} */
11
- const frag = document.createDocumentFragment();
12
-
13
- /** @type {string[]} */
14
- const lines = (src || '').replace(/\r\n?/g, '\n').split('\n');
15
-
16
- /** @type {boolean} */
17
- let in_code = false;
18
- /** @type {string[]} */
19
- let code_acc = [];
20
-
21
- /** @type {HTMLElement|null} */
22
- let list_el = null;
23
- /** @type {string} */
24
- let list_type = '';
25
-
26
- /**
27
- * Flush current paragraph buffer to <p> if any.
28
- * @param {string[]} buf
29
- */
30
- function flushParagraph(buf) {
31
- if (buf.length === 0) {
32
- return;
33
- }
34
- const p = document.createElement('p');
35
- appendInline(p, buf.join(' '));
36
- frag.appendChild(p);
37
- buf.length = 0;
38
- }
39
-
40
- /** @type {string[]} */
41
- const para = [];
42
-
43
- for (let i = 0; i < lines.length; i++) {
44
- const raw = lines[i];
45
- const line = raw;
46
-
47
- // fenced code blocks
48
- if (/^```/.test(line)) {
49
- if (!in_code) {
50
- in_code = true;
51
- code_acc = [];
52
- } else {
53
- // flush code block
54
- const pre = document.createElement('pre');
55
- const code = document.createElement('code');
56
- code.textContent = code_acc.join('\n');
57
- pre.appendChild(code);
58
- frag.appendChild(pre);
59
- in_code = false;
60
- code_acc = [];
61
- }
62
- continue;
63
- }
64
- if (in_code) {
65
- code_acc.push(raw);
66
- continue;
67
- }
68
-
69
- // blank line -> paragraph / list break
70
- if (/^\s*$/.test(line)) {
71
- flushParagraph(para);
72
- if (list_el !== null) {
73
- frag.appendChild(list_el);
74
- list_el = null;
75
- list_type = '';
76
- }
77
- continue;
78
- }
79
-
80
- // heading
81
- const hx = /^(#{1,6})\s+(.*)$/.exec(line);
82
- if (hx) {
83
- flushParagraph(para);
84
- if (list_el !== null) {
85
- frag.appendChild(list_el);
86
- list_el = null;
87
- list_type = '';
88
- }
89
- const level = Math.min(6, hx[1].length);
90
- const h = /** @type {HTMLHeadingElement} */ (
91
- document.createElement('h' + String(level))
92
- );
93
- appendInline(h, hx[2]);
94
- frag.appendChild(h);
95
- continue;
96
- }
97
-
98
- // list item
99
- let m = /^\s*[-*]\s+(.*)$/.exec(line);
100
- if (m) {
101
- if (list_el === null || list_type !== 'ul') {
102
- flushParagraph(para);
103
- if (list_el !== null) {
104
- frag.appendChild(list_el);
105
- }
106
- list_el = document.createElement('ul');
107
- list_type = 'ul';
108
- }
109
- const li = document.createElement('li');
110
- appendInline(li, m[1]);
111
- list_el.appendChild(li);
112
- continue;
113
- }
114
- m = /^\s*(\d+)\.\s+(.*)$/.exec(line);
115
- if (m) {
116
- if (list_el === null || list_type !== 'ol') {
117
- flushParagraph(para);
118
- if (list_el !== null) {
119
- frag.appendChild(list_el);
120
- }
121
- list_el = document.createElement('ol');
122
- list_type = 'ol';
123
- }
124
- const li = document.createElement('li');
125
- appendInline(li, m[2]);
126
- list_el.appendChild(li);
127
- continue;
128
- }
129
-
130
- // otherwise: paragraph text; accumulate to join with spaces
131
- para.push(line.trim());
132
- }
133
-
134
- // flush leftovers
135
- if (in_code) {
136
- const pre = document.createElement('pre');
137
- const code = document.createElement('code');
138
- code.textContent = code_acc.join('\n');
139
- pre.appendChild(code);
140
- frag.appendChild(pre);
141
- }
142
- flushParagraph(para);
143
- if (list_el !== null) {
144
- frag.appendChild(list_el);
145
- }
146
-
147
- return frag;
148
-
149
- /**
150
- * Append inline content parsing backticks and links safely.
151
- * - Inline code: `text`
152
- * - Links: [text](url) where url starts with http, https, or mailto
153
- * @param {HTMLElement} el
154
- * @param {string} text
155
- */
156
- function appendInline(el, text) {
157
- /** @type {RegExp} */
158
- const re = /(`[^`]+`)|(\[[^\]]+\]\([^)]+\))/g;
159
- /** @type {number} */
160
- let last = 0;
161
- /** @type {any} */
162
- let match = re.exec(text);
163
- while (match) {
164
- const idx = match.index;
165
- if (idx > last) {
166
- el.appendChild(document.createTextNode(text.slice(last, idx)));
167
- }
168
- const token = match[0];
169
- if (token[0] === '\u0060') {
170
- const code = document.createElement('code');
171
- code.textContent = token.slice(1, -1);
172
- el.appendChild(code);
173
- } else if (token[0] === '[') {
174
- // parse [text](url)
175
- const m2 = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
176
- if (m2) {
177
- const a = document.createElement('a');
178
- const href = m2[2].trim();
179
- const allowed = /^(https?:|mailto:)/i.test(href);
180
- if (allowed) {
181
- a.setAttribute('href', href);
182
- } else {
183
- // if not allowed, render as text to avoid XSS vectors
184
- a.remove();
185
- el.appendChild(document.createTextNode(m2[1] + ' (' + href + ')'));
186
- last = re.lastIndex;
187
- match = re.exec(text);
188
- continue;
189
- }
190
- a.appendChild(document.createTextNode(m2[1]));
191
- el.appendChild(a);
192
- }
193
- }
194
- last = re.lastIndex;
195
- match = re.exec(text);
196
- }
197
- if (last < text.length) {
198
- el.appendChild(document.createTextNode(text.slice(last)));
199
- }
200
- }
12
+ export function renderMarkdown(markdown) {
13
+ const parsed = /** @type {string} */ (marked.parse(markdown));
14
+ const html_string = DOMPurify.sanitize(parsed);
15
+ return unsafeHTML(html_string);
201
16
  }
@@ -2,13 +2,12 @@ import { priority_levels } from './priority.js';
2
2
 
3
3
  /**
4
4
  * Create a colored badge for a priority value (0..4).
5
+ *
5
6
  * @param {number | null | undefined} priority
6
7
  * @returns {HTMLSpanElement}
7
8
  */
8
9
  export function createPriorityBadge(priority) {
9
- /** @type {number} */
10
10
  const p = typeof priority === 'number' ? priority : 2;
11
- /** @type {HTMLSpanElement} */
12
11
  const el = document.createElement('span');
13
12
  el.className = 'priority-badge';
14
13
  el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Create a colored badge for a status value.
3
+ *
3
4
  * @param {string | null | undefined} status - 'open' | 'in_progress' | 'closed'
4
5
  * @returns {HTMLSpanElement}
5
6
  */
6
7
  export function createStatusBadge(status) {
7
- /** @type {HTMLSpanElement} */
8
8
  const el = document.createElement('span');
9
9
  el.className = 'status-badge';
10
10
  const s = String(status || 'open');
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Known status values in canonical order.
3
+ *
3
4
  * @type {Array<'open'|'in_progress'|'closed'>}
4
5
  */
5
6
  export const STATUSES = ['open', 'in_progress', 'closed'];
6
7
 
7
8
  /**
8
9
  * Map canonical status to display label.
10
+ *
9
11
  * @param {string | null | undefined} status
10
12
  * @returns {string}
11
13
  */
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Show a transient global toast message anchored to the viewport.
3
+ *
3
4
  * @param {string} text - Message text.
4
5
  * @param {'info'|'success'|'error'} [variant] - Visual variant.
5
6
  * @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
6
7
  */
7
8
  export function showToast(text, variant = 'info', duration_ms = 2800) {
8
- /** @type {HTMLDivElement} */
9
9
  const el = document.createElement('div');
10
10
  el.className = 'toast';
11
11
  el.textContent = text;
@@ -1,16 +1,14 @@
1
1
  /**
2
2
  * Create a compact, colored badge for an issue type.
3
+ *
3
4
  * @param {string | undefined | null} issue_type - One of: bug, feature, task, epic, chore
4
5
  * @returns {HTMLSpanElement}
5
6
  */
6
7
  export function createTypeBadge(issue_type) {
7
- /** @type {HTMLSpanElement} */
8
8
  const el = document.createElement('span');
9
9
  el.className = 'type-badge';
10
10
 
11
- /** @type {string} */
12
11
  const t = (issue_type || '').toString().toLowerCase();
13
- /** @type {Set<string>} */
14
12
  const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
15
13
  const kind = KNOWN.has(t) ? t : 'neutral';
16
14
  el.classList.add(`type-badge--${kind}`);