@lovett/ui 0.0.10 → 0.1.0

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 (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -0,0 +1,331 @@
1
+ /**
2
+ * CommentBody under hostile and malformed markdown — ADR-148 AC#5, AC#6, AC#8.
3
+ *
4
+ * Three assertions per corpus entry (AC#6):
5
+ * 1. rendering does not throw (and React logs no error);
6
+ * 2. nothing user-controlled became executable — no script/iframe/object/
7
+ * embed element, no `on*` handler attribute, no `javascript:` / `data:` /
8
+ * `vbscript:` href;
9
+ * 3. no text was silently dropped — every VISIBLE piece of the input is in
10
+ * `textContent`. "Visible" is what a reader of the source would expect
11
+ * to see: link and mention TEXT, never a destination (a URL is an
12
+ * attribute in every renderer); a horizontal rule has no words.
13
+ *
14
+ * Cross-comment isolation (AC#5): two bodies rendered as `comment.tsx` does —
15
+ * each its own `<CommentBody>` — and the second one's DOM must be
16
+ * byte-identical whether the first is benign or a construct CommonMark leaves
17
+ * open (an unclosed fence swallows the rest of ITS comment, by spec).
18
+ *
19
+ * URL allowlist (AC#8): exactly the three mention schemes pass through; every
20
+ * other scheme goes to `defaultUrlTransform`, and the dangerous ones render
21
+ * with a null or empty href (`defaultUrlTransform` returns '' and React emits
22
+ * the attribute empty — inert either way).
23
+ *
24
+ * The underline marker is spelled as an escape (`PP`) so the file never holds
25
+ * the syntax literally and stays plain UTF-8 for the CLAUDE.md section 8 greps.
26
+ */
27
+
28
+ import { describe, expect, it, vi } from 'vitest'
29
+ import { fireEvent, render } from '@testing-library/react'
30
+ import { CommentBody, type MentionTarget } from '../comment-body'
31
+
32
+ /** `++` — two U+002B PLUS SIGN. */
33
+ const PP = '\u{2B}\u{2B}'
34
+
35
+ const EXECUTABLE =
36
+ 'script, iframe, object, embed, [onerror], [onload], [onclick], [onmouseover], [onfocus], [srcdoc]'
37
+ const DANGEROUS_HREF = /^\s*(?:javascript|data|vbscript)\s*:/i
38
+
39
+ interface Case {
40
+ readonly name: string
41
+ readonly body: string
42
+ /** Every entry must appear in `textContent`. Empty when the input has no words. */
43
+ readonly visible: readonly string[]
44
+ /** A selector that must match, for cases whose STRUCTURE is the point. */
45
+ readonly selector?: string
46
+ }
47
+
48
+ const LONG_LINE = 'lorem ipsum '.repeat(170).slice(0, 2000)
49
+ const ADVERSARIAL_LINE = `a${PP} `.repeat(500)
50
+
51
+ const CORPUS: readonly Case[] = [
52
+ { name: 'hello [', body: 'hello [', visible: ['hello ['] },
53
+ { name: 'hello ]', body: 'hello ]', visible: ['hello ]'] },
54
+ { name: 'hello [world', body: 'hello [world', visible: ['hello [world'] },
55
+ { name: 'hello ](url)', body: 'hello ](url)', visible: ['hello ](url)'] },
56
+ { name: 'hello `world', body: 'hello `world', visible: ['hello `world'] },
57
+ { name: 'hello **world', body: 'hello **world', visible: ['hello **world'] },
58
+ { name: 'hello _world', body: 'hello _world', visible: ['hello _world'] },
59
+ { name: 'a double quote', body: '"', visible: ['"'] },
60
+ { name: 'a single quote', body: "'", visible: ["'"] },
61
+ { name: 'one backslash', body: '\\', visible: ['\\'] },
62
+ // `\\` is a CommonMark escape for one backslash; `\\\\` for two. Rendering
63
+ // the escaped form is the spec, not loss.
64
+ { name: 'two backslashes', body: '\\\\', visible: ['\\'] },
65
+ { name: 'four backslashes', body: '\\\\\\\\', visible: ['\\\\'] },
66
+ { name: '<div>', body: '<div>', visible: ['<div>'] },
67
+ { name: '<div', body: '<div', visible: ['<div'] },
68
+ { name: '</div>', body: '</div>', visible: ['</div>'] },
69
+ { name: 'inline <div> in prose', body: 'hello <div>world</div>', visible: ['hello <div>world</div>'] },
70
+ { name: '<script>', body: '<script>alert(1)</script>', visible: ['<script>alert(1)</script>'] },
71
+ { name: 'inline <script> in prose', body: 'hi <script>alert(1)</script> there', visible: ['hi <script>alert(1)</script> there'] },
72
+ { name: '<img onerror>', body: 'x <img src=x onerror=alert(1)> y', visible: ['x <img src=x onerror=alert(1)> y'] },
73
+ { name: 'raw <a href="javascript:">', body: '<a href="javascript:alert(1)">click</a>', visible: ['<a href="javascript:alert(1)">click</a>'] },
74
+ { name: '[click](javascript:alert(1))', body: '[click](javascript:alert(1))', visible: ['click'], selector: 'a' },
75
+ { name: '[click](data:text/html,x)', body: '[click](data:text/html,x)', visible: ['click'], selector: 'a' },
76
+ { name: '[click](vbscript:msgbox)', body: '[click](vbscript:msgbox)', visible: ['click'], selector: 'a' },
77
+ { name: '@[foo](https://example.com)', body: '@[foo](https://example.com)', visible: ['@', 'foo'], selector: 'a[href="https://example.com"]' },
78
+ { name: '@[foo](user:not-valid)', body: '@[foo](user:not-valid)', visible: ['@', 'foo'] },
79
+ { name: 'an unclosed fence with two lines after it', body: '```\nline one\nline two', visible: ['line one', 'line two'], selector: 'pre' },
80
+ { name: 'a bare fence renders a code block', body: '```\nconst a = 1\n```', visible: ['const a = 1'], selector: 'pre' },
81
+ { name: 'an unclosed fence with a language', body: '```ts\nconst a = 1\nconst b = 2', visible: ['const a = 1', 'const b = 2'] },
82
+ { name: `${PP}unclosed`, body: `${PP}unclosed`, visible: [`${PP}unclosed`] },
83
+ { name: `${PP}text${PP} renders an underline`, body: `${PP}text${PP}`, visible: ['text'], selector: 'u' },
84
+ { name: 'nested **bold and _italic_**', body: 'nested **bold and _italic_**', visible: ['nested', 'bold and', 'italic'], selector: 'strong em' },
85
+ { name: '[`code`](https://example.com)', body: '[`code`](https://example.com)', visible: ['code'], selector: 'a code' },
86
+ { name: 'literal * asterisks *', body: 'literal * asterisks *', visible: ['literal * asterisks *'] },
87
+ { name: 'a 2,000-character single line', body: LONG_LINE, visible: [LONG_LINE.trimEnd()] },
88
+ { name: 'a 2,000-character line of unpaired markers', body: ADVERSARIAL_LINE, visible: [ADVERSARIAL_LINE.trimEnd()] },
89
+ { name: 'a body that is only ---', body: '---', visible: [], selector: 'hr' },
90
+ { name: 'an empty body', body: '', visible: [] },
91
+ { name: 'only whitespace', body: ' \n\t\n', visible: [] },
92
+ { name: 'a GFM table (out of the editor subset, still renders)', body: '| a | b |\n|---|---|\n| 1 | 2 |', visible: ['a', 'b', '1', '2'], selector: 'table' },
93
+ { name: 'a task list', body: '- [ ] open\n- [x] done', visible: ['open', 'done'] },
94
+ { name: 'an autolink literal', body: 'see https://example.com/path now', visible: ['see', 'https://example.com/path', 'now'], selector: 'a[href="https://example.com/path"]' },
95
+ { name: 'an angle-bracket javascript destination', body: '[x](<javascript:alert(1)>)', visible: ['x'] },
96
+ { name: 'an entity-encoded javascript scheme', body: '[x](javascript&#58;alert(1))', visible: ['x'] },
97
+ { name: 'an upper-case javascript scheme', body: '[x](JAVASCRIPT:alert(1))', visible: ['x'] },
98
+ { name: 'an image with a javascript source', body: '![alt](javascript:alert(1))', visible: ['alt'] },
99
+ { name: 'an image with a safe source', body: '![photo](https://example.com/x.png)', visible: ['photo'] },
100
+ ]
101
+
102
+ function renderBody(body: string, onSelectMention?: (target: MentionTarget) => void) {
103
+ return render(
104
+ <CommentBody
105
+ bodyMd={body}
106
+ {...(onSelectMention === undefined ? {} : { onSelectMention })}
107
+ />,
108
+ )
109
+ }
110
+
111
+ describe('CommentBody — malformed-markdown corpus (AC#6)', () => {
112
+ expect(LONG_LINE).toHaveLength(2000)
113
+ expect(ADVERSARIAL_LINE).toHaveLength(2000)
114
+
115
+ for (const { name, body, visible, selector } of CORPUS) {
116
+ it(name, () => {
117
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
118
+ let container: HTMLElement | undefined
119
+
120
+ // 1. No throw — and no React error either (a boundary would log, not throw).
121
+ expect(() => {
122
+ container = renderBody(body).container
123
+ }).not.toThrow()
124
+ expect(errors).not.toHaveBeenCalled()
125
+ errors.mockRestore()
126
+ if (container === undefined) throw new Error('unreachable: render returned nothing')
127
+
128
+ // 2. Nothing executable reached the DOM.
129
+ expect(container.querySelector(EXECUTABLE)).toBeNull()
130
+ for (const anchor of Array.from(container.querySelectorAll('a'))) {
131
+ expect(anchor.getAttribute('href') ?? '').not.toMatch(DANGEROUS_HREF)
132
+ }
133
+ for (const image of Array.from(container.querySelectorAll('img'))) {
134
+ expect(image.getAttribute('src') ?? '').not.toMatch(DANGEROUS_HREF)
135
+ }
136
+
137
+ // 3. Nothing visible was dropped.
138
+ const text = container.textContent ?? ''
139
+ for (const piece of visible) expect(text).toContain(piece)
140
+
141
+ if (selector !== undefined) expect(container.querySelector(selector)).not.toBeNull()
142
+ })
143
+ }
144
+
145
+ // Images are outside the composer's subset (ADR-148 D3; ADR-151 owns
146
+ // attachments) but typeable. They render as alt text and nothing else — no
147
+ // `<img>` at all, so no request to a user-supplied URL and no empty-`src`
148
+ // element (React used to warn about that one) when a dangerous URL is
149
+ // stripped.
150
+ it('an image renders its alt text and no <img>, whatever the source', () => {
151
+ for (const [body, alt] of [
152
+ ['![alt](javascript:alert(1))', 'alt'],
153
+ ['![photo](https://example.com/x.png)', 'photo'],
154
+ ['![diagram](data:image/svg+xml,x)', 'diagram'],
155
+ ] as const) {
156
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
157
+ const { container, unmount } = renderBody(body)
158
+ expect(errors, body).not.toHaveBeenCalled()
159
+ errors.mockRestore()
160
+ expect(container.querySelector('img'), body).toBeNull()
161
+ expect(container.textContent, body).toBe(alt)
162
+ unmount()
163
+ }
164
+ })
165
+
166
+ it('an image with no alt text renders nothing, and no <img>', () => {
167
+ const { container } = renderBody('![](https://example.com/x.png)')
168
+ expect(container.querySelector('img')).toBeNull()
169
+ expect(container.textContent).toBe('')
170
+ })
171
+ })
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // AC#5 — cross-comment isolation
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /** A second comment exercising every construct the composer can emit. */
178
+ const SECOND_BODY = [
179
+ `second **bold** *italic* ${PP}under${PP} ~~gone~~ [link](https://example.com) \`code\` @[Ada](user:usr_1)`,
180
+ '',
181
+ '# heading',
182
+ '',
183
+ '- item one',
184
+ '- item two',
185
+ '',
186
+ '> quoted',
187
+ '',
188
+ '```',
189
+ 'fenced',
190
+ '```',
191
+ '',
192
+ '---',
193
+ '',
194
+ 'last line',
195
+ ].join('\n')
196
+
197
+ const BENIGN_FIRST = 'a perfectly ordinary first comment'
198
+
199
+ /** `[name, body, visible words]` — the words a reader of the source expects to see. */
200
+ const OPEN_FIRSTS: ReadonlyArray<readonly [name: string, body: string, visible: readonly string[]]> = [
201
+ ['an unclosed fence', '```\nline one\nline two', ['line one', 'line two']],
202
+ ['an unclosed fence with a language', '```ts\nconst x = 1', ['const x = 1']],
203
+ [`an unclosed ${PP}`, `${PP}unclosed`, [`${PP}unclosed`]],
204
+ ['an open [', 'hello [', ['hello [']],
205
+ ['an open [ with text after', 'hello [world and more', ['hello [world and more']],
206
+ ['an unclosed **', 'hello **world', ['hello **world']],
207
+ ['an unclosed backtick', 'hello `world', ['hello `world']],
208
+ ['an unclosed raw tag', '<div', ['<div']],
209
+ ]
210
+
211
+ /** Render two bodies the way `comment.tsx` does — each its own instance — and return the second's DOM. */
212
+ function secondBodyHtml(first: string): string {
213
+ const { container, unmount } = render(
214
+ <>
215
+ <CommentBody bodyMd={first} />
216
+ <CommentBody bodyMd={SECOND_BODY} />
217
+ </>,
218
+ )
219
+ expect(container.children).toHaveLength(2)
220
+ const second = container.children[1]
221
+ const out = second?.outerHTML ?? ''
222
+ unmount()
223
+ return out
224
+ }
225
+
226
+ describe('CommentBody — cross-comment isolation (AC#5)', () => {
227
+ const control = secondBodyHtml(BENIGN_FIRST)
228
+
229
+ it('the control second body actually rendered its structure', () => {
230
+ // Guards against the equality below being vacuous.
231
+ for (const needle of ['<strong', '<em', '<u ', '<del', '<a ', '<code', '<pre', 'Copy code', '<h1', '<li', '<blockquote', '<hr', 'Ada', 'last line']) {
232
+ expect(control).toContain(needle)
233
+ }
234
+ })
235
+
236
+ for (const [name, first] of OPEN_FIRSTS) {
237
+ it(`is byte-identical after ${name}`, () => {
238
+ expect(secondBodyHtml(first)).toBe(control)
239
+ })
240
+ }
241
+
242
+ it('the open first body still renders its own text (strangely, by itself)', () => {
243
+ for (const [name, first, visible] of OPEN_FIRSTS) {
244
+ const { container, unmount } = renderBody(first)
245
+ const text = container.textContent ?? ''
246
+ for (const word of visible) expect(text, name).toContain(word)
247
+ unmount()
248
+ }
249
+ })
250
+ })
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // AC#8 — the URL allowlist stays exact
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe('CommentBody — URL allowlist (AC#8)', () => {
257
+ const MENTIONS: ReadonlyArray<readonly [scheme: MentionTarget['type'], id: string]> = [
258
+ ['user', 'usr_9f2'],
259
+ ['task_item', 'tit_44a'],
260
+ ['task_board', 'tbd_1'],
261
+ ]
262
+
263
+ for (const [scheme, id] of MENTIONS) {
264
+ it(`passes ${scheme}: through as a mention chip`, () => {
265
+ const onSelectMention = vi.fn<(target: MentionTarget) => void>()
266
+ const { container, unmount } = renderBody(`@[Target](${scheme}:${id})`, onSelectMention)
267
+ // Not an anchor — the href never reaches the DOM as a link at all.
268
+ expect(container.querySelector('a')).toBeNull()
269
+ const chip = container.querySelector('button')
270
+ expect(chip).not.toBeNull()
271
+ expect(chip?.textContent).toBe('Target')
272
+ if (chip !== null) fireEvent.click(chip)
273
+ expect(onSelectMention).toHaveBeenCalledTimes(1)
274
+ expect(onSelectMention).toHaveBeenCalledWith({ type: scheme, id })
275
+ unmount()
276
+ })
277
+
278
+ it(`renders ${scheme}: as an inert chip when there is no handler`, () => {
279
+ const { container, unmount } = renderBody(`@[Target](${scheme}:${id})`)
280
+ expect(container.querySelector('a')).toBeNull()
281
+ expect(container.querySelector('button')).toBeNull()
282
+ expect(container.textContent).toBe('@Target')
283
+ unmount()
284
+ })
285
+ }
286
+
287
+ it('javascript:, data: and vbscript: hrefs render with a null or empty href', () => {
288
+ for (const url of [
289
+ 'javascript:alert(1)',
290
+ 'JaVaScRiPt:alert(1)',
291
+ 'javascript&#58;alert(1)',
292
+ 'data:text/html,x',
293
+ 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
294
+ 'vbscript:msgbox',
295
+ '<javascript:alert(1)>',
296
+ ]) {
297
+ const { container, unmount } = renderBody(`[click](${url})`)
298
+ const anchor = container.querySelector('a')
299
+ expect(anchor, url).not.toBeNull()
300
+ expect(anchor?.textContent, url).toBe('click')
301
+ // react-markdown's `defaultUrlTransform` returns '' for an unsafe
302
+ // protocol; React then emits the attribute empty. Either shape is inert
303
+ // and neither begins with the scheme — that is the outcome pinned here.
304
+ const href = anchor?.getAttribute('href')
305
+ expect(href === null || href === '', `${url} -> ${String(href)}`).toBe(true)
306
+ unmount()
307
+ }
308
+ })
309
+
310
+ it('does not widen the allowlist to look-alike schemes', () => {
311
+ for (const url of ['users:usr_1', 'user_x:1', 'task:tit_1', 'task_items:1', 'note:abc', 'user:', 'USER:usr_1']) {
312
+ const { container, unmount } = renderBody(`[x](${url})`)
313
+ // A near-miss is an ordinary link, not a chip...
314
+ expect(container.querySelector('button, span[class*="inline-flex"]'), url).toBeNull()
315
+ const anchor = container.querySelector('a')
316
+ expect(anchor, url).not.toBeNull()
317
+ // ...and its unknown scheme is dropped by the default transform.
318
+ const href = anchor?.getAttribute('href')
319
+ expect(href === null || href === '', `${url} -> ${String(href)}`).toBe(true)
320
+ unmount()
321
+ }
322
+ })
323
+
324
+ it('keeps ordinary safe hrefs exactly', () => {
325
+ for (const url of ['https://example.com/a?b=c:d', 'http://example.com', 'mailto:ada@example.com', '/relative/path', '#fragment', '?q=a:b']) {
326
+ const { container, unmount } = renderBody(`[x](${url})`)
327
+ expect(container.querySelector('a')?.getAttribute('href'), url).toBe(url)
328
+ unmount()
329
+ }
330
+ })
331
+ })
@@ -0,0 +1,151 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * comment-tree — the shape contract, and the D4 correction.
4
+ *
5
+ * These are pure-function tests in the node environment: no DOM, no clock, no
6
+ * randomness. The two that matter most are the deleted-parent and absent-parent
7
+ * cases, because the reference collapsed them into one behaviour and that is
8
+ * the bug ADR-147 D4 was written to stop.
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest'
12
+ import {
13
+ buildCommentTree,
14
+ countCommentTree,
15
+ countDescendants,
16
+ engagementScore,
17
+ sortCommentTree,
18
+ } from '../comment-tree'
19
+ import type { CommentNode, ThreadComment } from '../types'
20
+ import { FIXTURE_NOW, THREAD_FIXTURE } from './fixtures/thread-fixture'
21
+
22
+ function find(nodes: readonly CommentNode[], id: string): CommentNode | undefined {
23
+ for (const node of nodes) {
24
+ if (node.id === id) return node
25
+ const hit = find(node.replies, id)
26
+ if (hit !== undefined) return hit
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ const comment = (over: Partial<ThreadComment> & { id: string }): ThreadComment => ({
32
+ bodyMd: 'body',
33
+ createdAt: FIXTURE_NOW,
34
+ ...over,
35
+ })
36
+
37
+ describe('buildCommentTree', () => {
38
+ it('returns a flat list unchanged when nothing has a parent', () => {
39
+ const flat = [comment({ id: 'a' }), comment({ id: 'b' }), comment({ id: 'c' })]
40
+ const roots = buildCommentTree(flat)
41
+ expect(roots.map((n) => n.id)).toEqual(['a', 'b', 'c'])
42
+ expect(roots.every((n) => n.replies.length === 0)).toBe(true)
43
+ expect(roots.every((n) => n.orphanedParentId === null)).toBe(true)
44
+ })
45
+
46
+ it('nests by parentId and records the TRUE depth', () => {
47
+ const roots = buildCommentTree(THREAD_FIXTURE)
48
+ const deep3 = find(roots, 'c-deep-3')
49
+ expect(deep3).toBeDefined()
50
+ // Depth is stored true and capped only at render time (D3).
51
+ expect(deep3?.depth).toBe(3)
52
+ expect(find(roots, 'c-deep-0')?.depth).toBe(0)
53
+ })
54
+
55
+ it('D4 — a DELETED parent keeps its subtree', () => {
56
+ const roots = buildCommentTree(THREAD_FIXTURE)
57
+ const parent = roots.find((n) => n.id === 'c-deleted-parent')
58
+ expect(parent).toBeDefined()
59
+ expect(parent?.deletedAt).toBeTypeOf('number')
60
+ // The children are STILL under it, not promoted to top level.
61
+ expect(parent?.replies.map((n) => n.id)).toEqual([
62
+ 'c-deleted-child-a',
63
+ 'c-deleted-child-b',
64
+ ])
65
+ expect(roots.some((n) => n.id === 'c-deleted-child-a')).toBe(false)
66
+ })
67
+
68
+ it('D4 — an ABSENT parent surfaces the reply at top level AND flags it', () => {
69
+ const roots = buildCommentTree(THREAD_FIXTURE)
70
+ const orphan = roots.find((n) => n.id === 'c-orphan')
71
+ expect(orphan).toBeDefined()
72
+ // Never dropped...
73
+ expect(orphan?.bodyMd).toContain('pacing')
74
+ // ...and never silently promoted as if it were a new remark.
75
+ expect(orphan?.orphanedParentId).toBe('c-not-on-this-page')
76
+ })
77
+
78
+ it('does not recurse forever on a parent cycle', () => {
79
+ const cyclic = [
80
+ comment({ id: 'a', parentId: 'b' }),
81
+ comment({ id: 'b', parentId: 'a' }),
82
+ ]
83
+ const roots = buildCommentTree(cyclic)
84
+ expect(countCommentTree(roots)).toBe(2)
85
+ expect(roots.every((n) => n.orphanedParentId !== null)).toBe(true)
86
+ })
87
+
88
+ it('counts every node at every depth', () => {
89
+ const roots = buildCommentTree(THREAD_FIXTURE)
90
+ expect(countCommentTree(roots)).toBe(THREAD_FIXTURE.length)
91
+ })
92
+
93
+ it('countDescendants excludes the node itself', () => {
94
+ const roots = buildCommentTree(THREAD_FIXTURE)
95
+ const many = roots.find((n) => n.id === 'c-many')
96
+ expect(many).toBeDefined()
97
+ expect(many?.replies.length).toBe(8)
98
+ expect(many === undefined ? -1 : countDescendants(many)).toBe(8)
99
+ })
100
+ })
101
+
102
+ describe('engagementScore', () => {
103
+ it('prefers an explicit vote score', () => {
104
+ const [node] = buildCommentTree([comment({ id: 'a', score: 12 })])
105
+ expect(node === undefined ? -1 : engagementScore(node)).toBe(12)
106
+ })
107
+
108
+ it('otherwise totals the reaction counts', () => {
109
+ const [node] = buildCommentTree([
110
+ comment({
111
+ id: 'a',
112
+ reactions: [
113
+ { key: 'up', count: 3, viewerReacted: false },
114
+ { key: 'heart', count: 2, viewerReacted: true },
115
+ ],
116
+ }),
117
+ ])
118
+ expect(node === undefined ? -1 : engagementScore(node)).toBe(5)
119
+ })
120
+ })
121
+
122
+ describe('sortCommentTree', () => {
123
+ const roots = buildCommentTree(THREAD_FIXTURE)
124
+
125
+ it('top puts pinned first and undelivered last', () => {
126
+ const sorted = sortCommentTree(roots, 'top')
127
+ expect(sorted[0]?.id).toBe('c-reactions')
128
+ const ids = sorted.map((n) => n.id)
129
+ expect(ids.indexOf('c-pending')).toBeGreaterThan(ids.indexOf('c-one-word'))
130
+ expect(ids.indexOf('c-failed')).toBeGreaterThan(ids.indexOf('c-one-word'))
131
+ })
132
+
133
+ it('newest puts the most recent root first', () => {
134
+ const sorted = sortCommentTree(roots, 'new')
135
+ expect(sorted[0]?.id).toBe('c-pending')
136
+ })
137
+
138
+ it('keeps replies chronological whichever sort the roots use', () => {
139
+ for (const sort of ['top', 'new'] as const) {
140
+ const many = sortCommentTree(roots, sort).find((n) => n.id === 'c-many')
141
+ const times = (many?.replies ?? []).map((n) => n.createdAt)
142
+ expect(times).toEqual([...times].sort((a, b) => a - b))
143
+ }
144
+ })
145
+
146
+ it('is deterministic — the same input always sorts the same way', () => {
147
+ const a = sortCommentTree(roots, 'top').map((n) => n.id)
148
+ const b = sortCommentTree(buildCommentTree(THREAD_FIXTURE), 'top').map((n) => n.id)
149
+ expect(a).toEqual(b)
150
+ })
151
+ })
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The curated shortcode table (ADR-149 D3), and the source-byte discipline it
3
+ * has to keep (AC#9).
4
+ *
5
+ * Two things are being defended here. The first is behaviour: the search has
6
+ * to find what a person actually types, in the dialect they know. The second
7
+ * is a property of the FILE rather than the code — every glyph is written as a
8
+ * `\u{...}` escape, so the table can grow to a thousand emoji without a single
9
+ * raw high byte reaching a source file. A raw high byte makes a file grep as
10
+ * binary, which silently blinds the CLAUDE.md §8 gates; ADR-147 D8 learned
11
+ * that once and this is the assertion that keeps it learned.
12
+ */
13
+
14
+ import { readFileSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { describe, expect, it } from 'vitest'
17
+ import {
18
+ EMOJI_RESULT_LIMIT,
19
+ THREAD_EMOJI,
20
+ emojiByShortcode,
21
+ normalizeShortcode,
22
+ searchEmoji,
23
+ type EmojiEntry,
24
+ } from '../emoji'
25
+
26
+ /* ── D3 — the table itself ────────────────────────────────────────────────── */
27
+
28
+ describe('D3 — the table', () => {
29
+ it('is the size the ADR budgeted for: curated, not a dataset', () => {
30
+ expect(THREAD_EMOJI.length).toBeGreaterThanOrEqual(120)
31
+ expect(THREAD_EMOJI.length).toBeLessThanOrEqual(160)
32
+ })
33
+
34
+ it('has no duplicate name, glyph or shortcode — a lookup can only mean one thing', () => {
35
+ const names = new Set<string>()
36
+ const glyphs = new Set<string>()
37
+ const codes = new Set<string>()
38
+ for (const entry of THREAD_EMOJI) {
39
+ expect(names.has(entry.name)).toBe(false)
40
+ names.add(entry.name)
41
+ expect(glyphs.has(entry.glyph)).toBe(false)
42
+ glyphs.add(entry.glyph)
43
+ for (const code of [entry.name, ...entry.aliases]) {
44
+ const key = normalizeShortcode(code)
45
+ expect(codes.has(key)).toBe(false)
46
+ codes.add(key)
47
+ }
48
+ }
49
+ })
50
+
51
+ it('spells every shortcode in the character set a `:` query can actually carry', () => {
52
+ // The trigger's own match is `:[^\s:]*`, so a shortcode holding a space or
53
+ // a colon would be unreachable by typing — a dead row in the table.
54
+ for (const entry of THREAD_EMOJI) {
55
+ for (const code of [entry.name, ...entry.aliases]) {
56
+ expect(code).toMatch(/^[a-z0-9_+-]+$/)
57
+ }
58
+ }
59
+ })
60
+
61
+ it('carries a real glyph on every row', () => {
62
+ for (const entry of THREAD_EMOJI) {
63
+ expect(entry.glyph.length).toBeGreaterThan(0)
64
+ expect([...entry.glyph].every((char) => char.codePointAt(0)! > 0x7f)).toBe(true)
65
+ }
66
+ })
67
+ })
68
+
69
+ /* ── D3 — the lookup ──────────────────────────────────────────────────────── */
70
+
71
+ describe('D3 — resolving a shortcode', () => {
72
+ it('AC#3: `:side-eye:` resolves to the rolling-eyes glyph — the owner example', () => {
73
+ expect(emojiByShortcode('side-eye')?.glyph).toBe('\u{1F644}')
74
+ expect(emojiByShortcode(':side-eye:')?.glyph).toBe('\u{1F644}')
75
+ })
76
+
77
+ it('treats `-` and `_` as the same letter, so muscle memory works in any dialect', () => {
78
+ const rolling: EmojiEntry | undefined = emojiByShortcode('roll_eyes')
79
+ expect(rolling?.glyph).toBe('\u{1F644}')
80
+ expect(emojiByShortcode('side_eye')).toBe(rolling)
81
+ expect(emojiByShortcode('SIDE-EYE')).toBe(rolling)
82
+ expect(emojiByShortcode('face_with_rolling_eyes')).toBe(rolling)
83
+ })
84
+
85
+ it('resolves the shortcodes people reach for most', () => {
86
+ expect(emojiByShortcode('tada')?.glyph).toBe('\u{1F389}')
87
+ expect(emojiByShortcode('rocket')?.glyph).toBe('\u{1F680}')
88
+ expect(emojiByShortcode('thumbsup')?.glyph).toBe('\u{1F44D}')
89
+ expect(emojiByShortcode('+1')?.glyph).toBe('\u{1F44D}')
90
+ expect(emojiByShortcode('eyes')?.glyph).toBe('\u{1F440}')
91
+ expect(emojiByShortcode('heart')?.glyph).toBe('\u{2764}\u{FE0F}')
92
+ })
93
+
94
+ it('returns nothing for a shortcode the table does not hold', () => {
95
+ expect(emojiByShortcode('definitely_not_an_emoji')).toBeUndefined()
96
+ expect(emojiByShortcode('')).toBeUndefined()
97
+ })
98
+ })
99
+
100
+ /* ── D4 — the search ──────────────────────────────────────────────────────── */
101
+
102
+ describe('D4 — prefix search over names, aliases and keywords', () => {
103
+ it('finds an entry by the prefix of its name', () => {
104
+ expect(searchEmoji('tad')[0]?.glyph).toBe('\u{1F389}')
105
+ expect(searchEmoji('roc')[0]?.glyph).toBe('\u{1F680}')
106
+ })
107
+
108
+ it("D3's worked example: `:party:` finds the party popper, whose name is `tada`", () => {
109
+ expect(searchEmoji('party')[0]?.name).toBe('tada')
110
+ })
111
+
112
+ it('finds an entry by an alias, in the dialect that spells it that way', () => {
113
+ expect(searchEmoji('thumbsup')[0]?.glyph).toBe('\u{1F44D}')
114
+ expect(searchEmoji('shipped')[0]?.glyph).toBe('\u{1F389}')
115
+ })
116
+
117
+ it('ranks an exact name above a longer name that merely starts the same way', () => {
118
+ const results = searchEmoji('star')
119
+ expect(results[0]?.name).toBe('star')
120
+ })
121
+
122
+ it('returns nothing for an empty query — a bare colon is punctuation', () => {
123
+ expect(searchEmoji('')).toEqual([])
124
+ expect(searchEmoji(' ')).toEqual([])
125
+ })
126
+
127
+ it('returns nothing for a query nothing matches', () => {
128
+ expect(searchEmoji('zzzznope')).toEqual([])
129
+ })
130
+
131
+ it('bounds the result list, so the popup has a known maximum height', () => {
132
+ // `s` is a prefix of a great many keywords; the cap is what stops the
133
+ // popup from becoming a page.
134
+ expect(searchEmoji('s').length).toBeLessThanOrEqual(EMOJI_RESULT_LIMIT)
135
+ expect(searchEmoji('s', 3)).toHaveLength(3)
136
+ })
137
+
138
+ it('is case-insensitive and tolerates the colons a person types around it', () => {
139
+ expect(searchEmoji('TAD')[0]?.glyph).toBe('\u{1F389}')
140
+ expect(searchEmoji(':tada:')[0]?.glyph).toBe('\u{1F389}')
141
+ })
142
+ })
143
+
144
+ /* ── AC#9 — the source files stay text ────────────────────────────────────── */
145
+
146
+ /**
147
+ * Read a sibling source file. `.href` rather than the URL object itself
148
+ * because the global `URL` under jsdom is whatwg-url's, which Node's
149
+ * `fileURLToPath` does not always accept as one of its own.
150
+ */
151
+ function sourceOf(relative: string): string {
152
+ return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
153
+ }
154
+
155
+ describe('AC#9 — every glyph is an escape, never a raw byte', () => {
156
+ /**
157
+ * The only non-ASCII this codebase's TypeScript is allowed to carry, and it
158
+ * is all PROSE inside comments: the section rule, the three dashes, the
159
+ * ellipsis, the two arrows and the command key. No pictograph, no variation
160
+ * selector, nothing above the BMP. Adding to this list should feel like a
161
+ * decision; adding a raw emoji to it is the thing the list exists to stop.
162
+ */
163
+ const TYPOGRAPHY = new Set(
164
+ ['\u{00A7}', '\u{2013}', '\u{2014}', '\u{2019}', '\u{2026}', '\u{2192}', '\u{21B5}', '\u{2318}', '\u{2500}'].map(
165
+ (char) => char.codePointAt(0),
166
+ ),
167
+ )
168
+
169
+ const files = ['../emoji.ts', '../suggestion-list.tsx', './emoji.test.ts', './suggestion-list.test.tsx']
170
+
171
+ it.each(files)('%s holds no raw glyph', (relative) => {
172
+ const source = sourceOf(relative)
173
+ const offenders = [...source].filter((char) => {
174
+ const code = char.codePointAt(0) ?? 0
175
+ return code > 0x7f && !TYPOGRAPHY.has(code)
176
+ })
177
+ expect(offenders).toEqual([])
178
+ })
179
+
180
+ it('proves the point on the table itself: every glyph is absent from its own source', () => {
181
+ const source = sourceOf('../emoji.ts')
182
+ for (const entry of THREAD_EMOJI) {
183
+ expect(source.includes(entry.glyph)).toBe(false)
184
+ expect(source.includes(`'${entry.name}'`)).toBe(true)
185
+ }
186
+ })
187
+ })