@lovett/ui 0.0.11 → 0.2.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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-spec — the round-trip ADR-148 actually promises.
|
|
3
|
+
*
|
|
4
|
+
* AC#2 is PROPERTY-TESTED: a generator over D3's grammar (hand-rolled, seeded —
|
|
5
|
+
* no fast-check, no new dependency) produces markdown, and for every sample
|
|
6
|
+
* `parse → serialize → parse` yields an identical editor document and
|
|
7
|
+
* `serialize(parse(serialized)) === serialized`. Documents are compared after
|
|
8
|
+
* `normalizeRichDocument`, which is what `editor.setContent` produces — attrs
|
|
9
|
+
* with their defaults, content validated — so this is `editor.getJSON()`
|
|
10
|
+
* equality without a DOM.
|
|
11
|
+
*
|
|
12
|
+
* Deterministic on purpose (CLAUDE.md §7): the PRNG is mulberry32 with a fixed
|
|
13
|
+
* seed, so a failure names a reproducible sample.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
EMOJI_INPUT_RULE,
|
|
19
|
+
UNDERLINE_INPUT_RULE,
|
|
20
|
+
isRichEditable,
|
|
21
|
+
normalizeRichDocument,
|
|
22
|
+
parseRichMarkdown,
|
|
23
|
+
serializeRichMarkdown,
|
|
24
|
+
} from '../markdown-spec'
|
|
25
|
+
|
|
26
|
+
/* ── Seeded PRNG + generator over D3's grammar ────────────────────────────── */
|
|
27
|
+
|
|
28
|
+
type Rng = () => number
|
|
29
|
+
|
|
30
|
+
function mulberry32(seed: number): Rng {
|
|
31
|
+
let a = seed >>> 0
|
|
32
|
+
return () => {
|
|
33
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
34
|
+
let t = a
|
|
35
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
36
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
37
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pick<T>(rng: Rng, items: readonly T[]): T {
|
|
42
|
+
const item = items[Math.floor(rng() * items.length)]
|
|
43
|
+
if (item === undefined) throw new Error('empty choice')
|
|
44
|
+
return item
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function int(rng: Rng, min: number, max: number): number {
|
|
48
|
+
return min + Math.floor(rng() * (max - min + 1))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Plain words, plus words carrying LITERAL syntax already escaped as markdown. */
|
|
52
|
+
const WORDS = [
|
|
53
|
+
'flight',
|
|
54
|
+
'pacing',
|
|
55
|
+
'brief',
|
|
56
|
+
'client',
|
|
57
|
+
'quarter',
|
|
58
|
+
'creative',
|
|
59
|
+
'holds',
|
|
60
|
+
'a\\*b',
|
|
61
|
+
'c\\_d',
|
|
62
|
+
'x\\[y\\]',
|
|
63
|
+
'p\\+\\+q',
|
|
64
|
+
'\\~t',
|
|
65
|
+
'v1.2',
|
|
66
|
+
'C\\+\\+',
|
|
67
|
+
'a&b',
|
|
68
|
+
'x<y',
|
|
69
|
+
] as const
|
|
70
|
+
|
|
71
|
+
const URLS = [
|
|
72
|
+
'https://example.com/plan',
|
|
73
|
+
'https://example.com/a?b=1&c=2',
|
|
74
|
+
'mailto:team@example.com',
|
|
75
|
+
] as const
|
|
76
|
+
|
|
77
|
+
const MENTIONS = [
|
|
78
|
+
'@[Marcus Webb](user:usr_9f2)',
|
|
79
|
+
'@[Slice B migration](task_item:tit_44a)',
|
|
80
|
+
'@[Board](task_board:tbd_1)',
|
|
81
|
+
] as const
|
|
82
|
+
|
|
83
|
+
type Wrap = 'bold' | 'italic' | 'underline' | 'strike'
|
|
84
|
+
|
|
85
|
+
function word(rng: Rng): string {
|
|
86
|
+
return pick(rng, WORDS)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function inline(rng: Rng, depth: number, open: ReadonlySet<Wrap>, inLink: boolean): string {
|
|
90
|
+
const count = int(rng, 1, 3)
|
|
91
|
+
const parts: string[] = []
|
|
92
|
+
for (let index = 0; index < count; index += 1) {
|
|
93
|
+
// Inside a wrap, the FIRST item is never another wrap: an emphasis run
|
|
94
|
+
// that opens with a nested one (`***a* *b***`) is valid CommonMark that
|
|
95
|
+
// marked mis-parses — pinned below as a known limitation, kept out of
|
|
96
|
+
// the property by construction rather than by luck.
|
|
97
|
+
const first = index === 0 && open.size > 0
|
|
98
|
+
parts.push(first ? word(rng) : inlineItem(rng, depth, open, inLink))
|
|
99
|
+
}
|
|
100
|
+
return parts.join(' ')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inlineItem(rng: Rng, depth: number, open: ReadonlySet<Wrap>, inLink: boolean): string {
|
|
104
|
+
const roll = rng()
|
|
105
|
+
if (depth >= 2 || roll < 0.45) return word(rng)
|
|
106
|
+
// A code span only where no mark is open: TipTap's `code` mark excludes
|
|
107
|
+
// every other mark, so emphasis or a link OVER a code span is outside what
|
|
108
|
+
// the schema can hold — the detector fails it closed (asserted below).
|
|
109
|
+
if (roll < 0.55 && open.size === 0 && !inLink) return `\`${word(rng).replace(/\\/g, '')}\``
|
|
110
|
+
if (roll < 0.63 && !inLink) return pick(rng, MENTIONS)
|
|
111
|
+
if (roll < 0.72 && !inLink) return `[${inline(rng, depth + 1, open, true)}](${pick(rng, URLS)})`
|
|
112
|
+
const wraps: readonly Wrap[] = (['bold', 'italic', 'underline', 'strike'] as const).filter(
|
|
113
|
+
(wrap) => !open.has(wrap),
|
|
114
|
+
)
|
|
115
|
+
if (wraps.length === 0) return word(rng)
|
|
116
|
+
const wrap = pick(rng, wraps)
|
|
117
|
+
const next = new Set(open)
|
|
118
|
+
next.add(wrap)
|
|
119
|
+
const inner = inline(rng, depth + 1, next, inLink)
|
|
120
|
+
switch (wrap) {
|
|
121
|
+
case 'bold':
|
|
122
|
+
return `**${inner}**`
|
|
123
|
+
case 'italic':
|
|
124
|
+
return `*${inner}*`
|
|
125
|
+
case 'underline':
|
|
126
|
+
return `++${inner}++`
|
|
127
|
+
case 'strike':
|
|
128
|
+
return `~~${inner}~~`
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function block(rng: Rng): string {
|
|
133
|
+
const roll = rng()
|
|
134
|
+
const empty = new Set<Wrap>()
|
|
135
|
+
if (roll < 0.35) return inline(rng, 0, empty, false)
|
|
136
|
+
if (roll < 0.5) return `${'#'.repeat(int(rng, 1, 3))} ${inline(rng, 0, empty, false)}`
|
|
137
|
+
if (roll < 0.65) {
|
|
138
|
+
const items = int(rng, 1, 3)
|
|
139
|
+
return Array.from({ length: items }, () => `- ${inline(rng, 0, empty, false)}`).join('\n')
|
|
140
|
+
}
|
|
141
|
+
if (roll < 0.75) {
|
|
142
|
+
const items = int(rng, 1, 3)
|
|
143
|
+
return Array.from(
|
|
144
|
+
{ length: items },
|
|
145
|
+
(_, index) => `${String(index + 1)}. ${inline(rng, 0, empty, false)}`,
|
|
146
|
+
).join('\n')
|
|
147
|
+
}
|
|
148
|
+
if (roll < 0.85) return `> ${inline(rng, 0, empty, false)}`
|
|
149
|
+
if (roll < 0.93) {
|
|
150
|
+
const lang = pick(rng, ['', 'ts', 'sql'])
|
|
151
|
+
return `\`\`\`${lang}\nconst ${word(rng).replace(/\\/g, '')} = 1\n\`\`\``
|
|
152
|
+
}
|
|
153
|
+
return '---'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function document(rng: Rng): string {
|
|
157
|
+
const blocks = int(rng, 1, 4)
|
|
158
|
+
return Array.from({ length: blocks }, () => block(rng)).join('\n\n')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** parse → serialize → parse identity, and the serialized form is a fixed point. */
|
|
162
|
+
function assertRoundTrip(markdown: string): void {
|
|
163
|
+
const first = normalizeRichDocument(parseRichMarkdown(markdown))
|
|
164
|
+
const serialized = serializeRichMarkdown(first)
|
|
165
|
+
const second = normalizeRichDocument(parseRichMarkdown(serialized))
|
|
166
|
+
expect(second, `document drifted for:\n${markdown}\n→\n${serialized}`).toEqual(first)
|
|
167
|
+
expect(serializeRichMarkdown(second), `no fixed point for:\n${markdown}`).toBe(serialized)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe('AC#2 — semantic round-trip is lossless for the subset (property)', () => {
|
|
171
|
+
it('holds for 300 generated documents', () => {
|
|
172
|
+
const rng = mulberry32(0x5eed)
|
|
173
|
+
for (let sample = 0; sample < 300; sample += 1) {
|
|
174
|
+
assertRoundTrip(document(rng))
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it.each([
|
|
179
|
+
['nested emphasis', '**bold with *italic inside* it**'],
|
|
180
|
+
['italic wrapping bold', '*italic **bold** italic*'],
|
|
181
|
+
['code span inside a list item', '- item with `code` in it\n- second'],
|
|
182
|
+
['link text containing **', '[a **bold** label](https://example.com/plan)'],
|
|
183
|
+
['a literal asterisk', 'a \\* b'],
|
|
184
|
+
['++ inside **', '**bold ++underlined++ bold**'],
|
|
185
|
+
['an h3 directly above a list', '### Title\n\n- one\n- two'],
|
|
186
|
+
['a divider between paragraphs', 'first\n\n---\n\nsecond'],
|
|
187
|
+
['a mention in running text', 'Flagging @[Priya Raman](user:u2) on the second one.'],
|
|
188
|
+
['ordered list starting past one', '3. three\n4. four'],
|
|
189
|
+
['blockquote with emphasis', '> a **quoted** line'],
|
|
190
|
+
['hard break', 'line one \nline two'],
|
|
191
|
+
['fenced code with a language', '```ts\nconst a = 1\n```'],
|
|
192
|
+
['emphasis spanning a mention', '*see @[Priya Raman](user:u2) here*'],
|
|
193
|
+
['nested emphasis with interior spaces', '*a **b** c*'],
|
|
194
|
+
['emphasis on a trailing space', '**bold **and more'],
|
|
195
|
+
['a code span with boundary spaces', 'x ` y ` z'],
|
|
196
|
+
])('%s', (_label, markdown) => {
|
|
197
|
+
assertRoundTrip(markdown)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('canonicalizes spelling rather than preserving it (D3 rule 1)', () => {
|
|
201
|
+
const doc = normalizeRichDocument(parseRichMarkdown('_italic_ and __bold__\n\n* item'))
|
|
202
|
+
expect(serializeRichMarkdown(doc)).toBe('*italic* and **bold**\n\n- item')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('KNOWN LIMITATION (marked): a bold run that opens with an italic and holds a second one', () => {
|
|
206
|
+
// CommonMark: strong(em(a) " " em(b)). marked 17 reads `***a* *b***` as
|
|
207
|
+
// one em(strong(...)) run, so the EDITOR shows a single bold-italic span
|
|
208
|
+
// where the renderer (micromark) shows two italics inside bold. The
|
|
209
|
+
// serializer's output is the correct CommonMark; the editor-side parse
|
|
210
|
+
// is where the loss happens. This pins the current behaviour so a marked
|
|
211
|
+
// fix — or a custom emphasis tokenizer — is noticed the day it lands.
|
|
212
|
+
const doc = normalizeRichDocument(parseRichMarkdown('***a* *b***'))
|
|
213
|
+
const nodes = doc.content?.[0]?.content ?? []
|
|
214
|
+
expect(nodes).toHaveLength(1)
|
|
215
|
+
expect(nodes[0]?.text).toBe('a* *b')
|
|
216
|
+
expect(nodes[0]?.marks?.map((mark) => mark.type).sort()).toEqual(['bold', 'italic'])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('keeps emphasis boundaries off whitespace, and leaves nested emphasis alone', () => {
|
|
220
|
+
// Italic on the space beside a mention cannot be spelled; it moves off.
|
|
221
|
+
const spanning = normalizeRichDocument(parseRichMarkdown('*see @[Priya Raman](user:u2) here*'))
|
|
222
|
+
expect(serializeRichMarkdown(spanning)).toBe('*see* @[Priya Raman](user:u2) *here*')
|
|
223
|
+
// Interior spaces keep their marks: the bold run is all three nodes.
|
|
224
|
+
const nested = normalizeRichDocument(parseRichMarkdown('**bold with *italic inside* it**'))
|
|
225
|
+
expect(serializeRichMarkdown(nested)).toBe('**bold with *italic inside* it**')
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('AC#4 — mention and underline syntax round-trip', () => {
|
|
230
|
+
it('parses @[Name](user:id) into a mention node and serializes it back byte-for-byte', () => {
|
|
231
|
+
const doc = normalizeRichDocument(parseRichMarkdown('Hi @[Marcus Webb](user:usr_9f2) there'))
|
|
232
|
+
const paragraph = doc.content?.[0]
|
|
233
|
+
const mention = paragraph?.content?.find((node) => node.type === 'mention')
|
|
234
|
+
expect(mention?.attrs).toMatchObject({ id: 'usr_9f2', label: 'Marcus Webb', type: 'user' })
|
|
235
|
+
expect(serializeRichMarkdown(doc)).toBe('Hi @[Marcus Webb](user:usr_9f2) there')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it.each(['task_item:tit_44a', 'task_board:tbd_1'])('accepts the %s scheme', (href) => {
|
|
239
|
+
const markdown = `See @[Slice B](${href}).`
|
|
240
|
+
const doc = normalizeRichDocument(parseRichMarkdown(markdown))
|
|
241
|
+
expect(doc.content?.[0]?.content?.some((node) => node.type === 'mention')).toBe(true)
|
|
242
|
+
expect(serializeRichMarkdown(doc)).toBe(markdown)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('leaves an @[…](https://…) link alone — it is a link, not a mention', () => {
|
|
246
|
+
const markdown = 'ping @[x](https://example.com/x) please'
|
|
247
|
+
const doc = normalizeRichDocument(parseRichMarkdown(markdown))
|
|
248
|
+
const paragraph = doc.content?.[0]
|
|
249
|
+
expect(paragraph?.content?.some((node) => node.type === 'mention')).toBe(false)
|
|
250
|
+
const linked = paragraph?.content?.find((node) =>
|
|
251
|
+
(node.marks ?? []).some((mark) => mark.type === 'link'),
|
|
252
|
+
)
|
|
253
|
+
expect(linked?.text).toBe('x')
|
|
254
|
+
expect(serializeRichMarkdown(doc)).toBe(markdown)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('leaves an unknown scheme alone', () => {
|
|
258
|
+
const markdown = '@[x](mailto:x@example.com)'
|
|
259
|
+
const doc = normalizeRichDocument(parseRichMarkdown(markdown))
|
|
260
|
+
expect(doc.content?.[0]?.content?.some((node) => node.type === 'mention')).toBe(false)
|
|
261
|
+
expect(serializeRichMarkdown(doc)).toBe(markdown)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('escapes brackets in a mention label and reads them back', () => {
|
|
265
|
+
const doc = normalizeRichDocument(
|
|
266
|
+
parseRichMarkdown('@[Ops \\[west\\]](task_board:tbd_9)'),
|
|
267
|
+
)
|
|
268
|
+
const mention = doc.content?.[0]?.content?.find((node) => node.type === 'mention')
|
|
269
|
+
expect(mention?.attrs?.['label']).toBe('Ops [west]')
|
|
270
|
+
expect(serializeRichMarkdown(doc)).toBe('@[Ops \\[west\\]](task_board:tbd_9)')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('parses ++text++ into an underline mark and serializes it back', () => {
|
|
274
|
+
const doc = normalizeRichDocument(parseRichMarkdown('a ++under lined++ b'))
|
|
275
|
+
const underlined = doc.content?.[0]?.content?.find((node) =>
|
|
276
|
+
(node.marks ?? []).some((mark) => mark.type === 'underline'),
|
|
277
|
+
)
|
|
278
|
+
expect(underlined?.text).toBe('under lined')
|
|
279
|
+
expect(serializeRichMarkdown(doc)).toBe('a ++under lined++ b')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it.each([
|
|
283
|
+
['whitespace touching the markers', '++ x ++'],
|
|
284
|
+
['whitespace before the closer', '++x ++'],
|
|
285
|
+
['a marker alone', 'a ++ b'],
|
|
286
|
+
['an unclosed opener', '++unclosed'],
|
|
287
|
+
])('treats %s as literal text', (_label, markdown) => {
|
|
288
|
+
const doc = normalizeRichDocument(parseRichMarkdown(markdown))
|
|
289
|
+
const marks = doc.content?.[0]?.content?.flatMap((node) => node.marks ?? []) ?? []
|
|
290
|
+
expect(marks.some((mark) => mark.type === 'underline')).toBe(false)
|
|
291
|
+
expect(doc.content?.[0]?.content?.map((node) => node.text).join('')).toBe(markdown)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('does not nest: the innermost pair wins and the outer markers stay literal', () => {
|
|
295
|
+
const doc = normalizeRichDocument(parseRichMarkdown('++a ++b++ c++'))
|
|
296
|
+
const nodes = doc.content?.[0]?.content ?? []
|
|
297
|
+
const underlined = nodes.filter((node) =>
|
|
298
|
+
(node.marks ?? []).some((mark) => mark.type === 'underline'),
|
|
299
|
+
)
|
|
300
|
+
expect(underlined.map((node) => node.text)).toEqual(['b'])
|
|
301
|
+
expect(nodes.map((node) => node.text).join('')).toBe('++a b c++')
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('serialization escapes what the manager does not', () => {
|
|
306
|
+
const paragraph = (text: string) => ({
|
|
307
|
+
type: 'doc',
|
|
308
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it.each([
|
|
312
|
+
['a literal ++ run', 'a++b++c', 'a\\+\\+b\\+\\+c'],
|
|
313
|
+
['a leading #', '# not a heading', '\\# not a heading'],
|
|
314
|
+
['a leading dash', '- not a list', '\\- not a list'],
|
|
315
|
+
['a leading plus', '+ not a list', '\\+ not a list'],
|
|
316
|
+
['a leading number', '1. not a list', '1\\. not a list'],
|
|
317
|
+
['a dashed rule', '---', '\\---'],
|
|
318
|
+
['a leading >', '> not a quote', '> not a quote'],
|
|
319
|
+
])('%s survives serialize → parse unchanged', (_label, text, expected) => {
|
|
320
|
+
const doc = normalizeRichDocument(paragraph(text))
|
|
321
|
+
const serialized = serializeRichMarkdown(doc)
|
|
322
|
+
expect(serialized).toBe(expected)
|
|
323
|
+
expect(normalizeRichDocument(parseRichMarkdown(serialized))).toEqual(doc)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('escapes a block starter after a hard break, not only at the paragraph start', () => {
|
|
327
|
+
const doc = normalizeRichDocument({
|
|
328
|
+
type: 'doc',
|
|
329
|
+
content: [
|
|
330
|
+
{
|
|
331
|
+
type: 'paragraph',
|
|
332
|
+
content: [
|
|
333
|
+
{ type: 'text', text: 'first' },
|
|
334
|
+
{ type: 'hardBreak' },
|
|
335
|
+
{ type: 'text', text: '# second' },
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
})
|
|
340
|
+
const serialized = serializeRichMarkdown(doc)
|
|
341
|
+
expect(normalizeRichDocument(parseRichMarkdown(serialized))).toEqual(doc)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('leaves code alone', () => {
|
|
345
|
+
const doc = normalizeRichDocument({
|
|
346
|
+
type: 'doc',
|
|
347
|
+
content: [
|
|
348
|
+
{ type: 'codeBlock', attrs: { language: null }, content: [{ type: 'text', text: '# ++x++' }] },
|
|
349
|
+
{
|
|
350
|
+
type: 'paragraph',
|
|
351
|
+
content: [{ type: 'text', text: '++x++', marks: [{ type: 'code' }] }],
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
})
|
|
355
|
+
expect(serializeRichMarkdown(doc)).toBe('```\n# ++x++\n```\n\n`++x++`')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('drops a stray private-use sentinel from user text instead of turning it into a backslash', () => {
|
|
359
|
+
const doc = normalizeRichDocument(paragraph('a\u{E000}b'))
|
|
360
|
+
expect(serializeRichMarkdown(doc)).toBe('ab')
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe('AC#3 — unsupported markdown never enters the rich editor', () => {
|
|
365
|
+
it.each([
|
|
366
|
+
['a table', '| a | b |\n|---|---|\n| 1 | 2 |'],
|
|
367
|
+
['an h4', '#### deep heading'],
|
|
368
|
+
['an h6', '###### deeper'],
|
|
369
|
+
['block raw HTML', '<div>hi</div>'],
|
|
370
|
+
['inline raw HTML', 'a <u>b</u> c'],
|
|
371
|
+
['a script tag', '<script>alert(1)</script>'],
|
|
372
|
+
['a task list', '- [ ] todo\n- [x] done'],
|
|
373
|
+
['an image', ''],
|
|
374
|
+
['a table nested in a quote', '> | a |\n> |---|\n> | 1 |'],
|
|
375
|
+
['an h4 inside a list', '- #### nope'],
|
|
376
|
+
// TipTap's `code` mark excludes all other marks: the schema cannot hold
|
|
377
|
+
// emphasis or a link over a code span, so these fail closed rather than
|
|
378
|
+
// silently dropping the outer mark.
|
|
379
|
+
['a code span inside emphasis', '*see `x` here*'],
|
|
380
|
+
['a code span inside a link', '[see `x`](https://example.com)'],
|
|
381
|
+
])('%s fails closed', (_label, markdown) => {
|
|
382
|
+
expect(isRichEditable(markdown)).toBe(false)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it.each([
|
|
386
|
+
['an empty body', ''],
|
|
387
|
+
['a plain paragraph', 'Looks right to me.'],
|
|
388
|
+
['every subset construct at once',
|
|
389
|
+
'# Title\n\n## Sub\n\n### Third\n\n**b** *i* ++u++ ~~s~~ `c` [l](https://example.com)\n\n- a\n- b\n\n1. one\n\n> quote\n\n```\ncode\n```\n\n---\n\n@[Marcus Webb](user:usr_1) line \nbreak'],
|
|
390
|
+
['non-canonical spelling', '_italic_ and __bold__\n\n* item'],
|
|
391
|
+
['malformed inline syntax (CommonMark says it is text)', 'hello [ and ](url) and `world and **bold'],
|
|
392
|
+
['an unclosed fence', '```\nnever closed'],
|
|
393
|
+
['an invalid mention target (it is a link)', '@[foo](user:not-valid) and @[x](nope:1)'],
|
|
394
|
+
['an autolink', '<https://example.com>'],
|
|
395
|
+
['an entity', 'a < b'],
|
|
396
|
+
])('%s opens rich', (_label, markdown) => {
|
|
397
|
+
expect(isRichEditable(markdown)).toBe(true)
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
describe('input rules — markdown that formats AS YOU TYPE', () => {
|
|
402
|
+
/**
|
|
403
|
+
* Owner, 2026-09-07: "is it not like obsidian where you type them in and it
|
|
404
|
+
* autoformats for you as you type?" It is, for everything StarterKit ships.
|
|
405
|
+
* The local underline mark (D13) had commands, a shortcut and full markdown
|
|
406
|
+
* serialization and NO input rule, so `++text++` was the one thing that did
|
|
407
|
+
* nothing as you typed.
|
|
408
|
+
*
|
|
409
|
+
* What is pinned here is the PATTERN. Input rules fire on real text input,
|
|
410
|
+
* which jsdom cannot faithfully simulate (`insertContent` bypasses them
|
|
411
|
+
* entirely), so an end-to-end test here would assert nothing. All thirteen
|
|
412
|
+
* shortcuts were verified in a real browser instead; the regex is where a
|
|
413
|
+
* bug would actually live, and that is testable.
|
|
414
|
+
*/
|
|
415
|
+
const fires = (typed: string): boolean => UNDERLINE_INPUT_RULE.test(typed)
|
|
416
|
+
|
|
417
|
+
it('fires on a well-formed run, at the line start or after whitespace', () => {
|
|
418
|
+
expect(fires('++under++')).toBe(true)
|
|
419
|
+
expect(fires('see ++under++')).toBe(true)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('does NOT fire on code — the flanking rule, not "no spaces"', () => {
|
|
423
|
+
// The defect this guards: a whitespace-only rule underlined "; j" here.
|
|
424
|
+
expect(fires('i++; j++;')).toBe(false)
|
|
425
|
+
expect(fires('C++')).toBe(false)
|
|
426
|
+
expect(fires('x = i++')).toBe(false)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('does NOT fire with whitespace inside the markers', () => {
|
|
430
|
+
expect(fires('++ under++')).toBe(false)
|
|
431
|
+
expect(fires('++under ++')).toBe(false)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('does NOT match greedily across several runs or a +++ run', () => {
|
|
435
|
+
expect(fires('+++x+++')).toBe(false)
|
|
436
|
+
// `[^+]+` stops the content swallowing a second pair.
|
|
437
|
+
const m = UNDERLINE_INPUT_RULE.exec('a ++one++')
|
|
438
|
+
expect(m?.[2]).toBe('one')
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('only fires at the END of the typed run — it is an input rule', () => {
|
|
442
|
+
expect(fires('++under++ trailing')).toBe(false)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
describe('closing-colon emoji shortcodes', () => {
|
|
446
|
+
const converts = (typed: string): boolean => EMOJI_INPUT_RULE(typed) !== null
|
|
447
|
+
|
|
448
|
+
it('matches canonical names and aliases at a text boundary', () => {
|
|
449
|
+
expect(converts(':tada:')).toBe(true)
|
|
450
|
+
expect(converts('ship it :side-eye:')).toBe(true)
|
|
451
|
+
expect(converts(':side_eye:')).toBe(true)
|
|
452
|
+
expect(converts(':TADA:')).toBe(true)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('does not match a shape the shortcode table does not actually hold', () => {
|
|
456
|
+
expect(converts(':not_a_real_emoji:')).toBe(false)
|
|
457
|
+
// `ship` is a SEARCH keyword for `tada`, not a declared shortcode.
|
|
458
|
+
expect(converts(':ship:')).toBe(false)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('leaves incomplete, spaced, and embedded prose literal', () => {
|
|
462
|
+
expect(converts(':')).toBe(false)
|
|
463
|
+
expect(converts(':tada')).toBe(false)
|
|
464
|
+
expect(converts(':side eye:')).toBe(false)
|
|
465
|
+
expect(converts('prefix:tada:')).toBe(false)
|
|
466
|
+
expect(converts(':tada: trailing')).toBe(false)
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* ADR-147 D16 — relative time is a PURE function of (value, now).
|
|
4
|
+
* Nothing here reads a clock, which is the whole point: a component that calls
|
|
5
|
+
* Date.now() while rendering cannot be frozen from outside and cannot be
|
|
6
|
+
* asserted on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
formatAbsoluteTime,
|
|
12
|
+
formatRelativeTime,
|
|
13
|
+
toIsoTimestamp,
|
|
14
|
+
} from '../relative-time'
|
|
15
|
+
import { FIXTURE_NOW } from './fixtures/thread-fixture'
|
|
16
|
+
|
|
17
|
+
const MINUTE = 60_000
|
|
18
|
+
const HOUR = 60 * MINUTE
|
|
19
|
+
const DAY = 24 * HOUR
|
|
20
|
+
|
|
21
|
+
describe('formatRelativeTime', () => {
|
|
22
|
+
it('reads "now" under a minute', () => {
|
|
23
|
+
expect(formatRelativeTime(FIXTURE_NOW, FIXTURE_NOW, 'en-GB')).toBe('now')
|
|
24
|
+
expect(formatRelativeTime(FIXTURE_NOW - 59_000, FIXTURE_NOW, 'en-GB')).toBe('now')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('counts minutes, hours, then days', () => {
|
|
28
|
+
expect(formatRelativeTime(FIXTURE_NOW - MINUTE, FIXTURE_NOW, 'en-GB')).toBe('1m')
|
|
29
|
+
expect(formatRelativeTime(FIXTURE_NOW - 59 * MINUTE, FIXTURE_NOW, 'en-GB')).toBe('59m')
|
|
30
|
+
expect(formatRelativeTime(FIXTURE_NOW - HOUR, FIXTURE_NOW, 'en-GB')).toBe('1h')
|
|
31
|
+
expect(formatRelativeTime(FIXTURE_NOW - 23 * HOUR, FIXTURE_NOW, 'en-GB')).toBe('23h')
|
|
32
|
+
expect(formatRelativeTime(FIXTURE_NOW - DAY, FIXTURE_NOW, 'en-GB')).toBe('1d')
|
|
33
|
+
expect(formatRelativeTime(FIXTURE_NOW - 6 * DAY, FIXTURE_NOW, 'en-GB')).toBe('6d')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('crosses over to an absolute date at seven days', () => {
|
|
37
|
+
const label = formatRelativeTime(FIXTURE_NOW - 8 * DAY, FIXTURE_NOW, 'en-GB')
|
|
38
|
+
expect(label).not.toMatch(/^\d+d$/)
|
|
39
|
+
expect(label).toContain('Aug')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('adds the year once the date is not in the current one', () => {
|
|
43
|
+
const label = formatRelativeTime(Date.UTC(2025, 0, 4), FIXTURE_NOW, 'en-GB')
|
|
44
|
+
expect(label).toContain('2025')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('treats clock skew from the future as "now" rather than a negative age', () => {
|
|
48
|
+
expect(formatRelativeTime(FIXTURE_NOW + 5 * MINUTE, FIXTURE_NOW, 'en-GB')).toBe('now')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns an empty string rather than NaN for unusable input', () => {
|
|
52
|
+
expect(formatRelativeTime(Number.NaN, FIXTURE_NOW, 'en-GB')).toBe('')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('toIsoTimestamp', () => {
|
|
57
|
+
it('carries the exact instant for the datetime attribute', () => {
|
|
58
|
+
expect(toIsoTimestamp(FIXTURE_NOW)).toBe('2026-09-06T12:00:00.000Z')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('degrades to an empty string rather than throwing', () => {
|
|
62
|
+
expect(toIsoTimestamp(Number.POSITIVE_INFINITY)).toBe('')
|
|
63
|
+
expect(toIsoTimestamp(Number.NaN)).toBe('')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('formatAbsoluteTime', () => {
|
|
68
|
+
it('produces something unambiguous for the hover title', () => {
|
|
69
|
+
expect(formatAbsoluteTime(FIXTURE_NOW, 'en-GB')).toContain('2026')
|
|
70
|
+
})
|
|
71
|
+
})
|