@furystack/shades-common-components 12.2.0 → 12.4.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 (100) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/esm/components/form.d.ts +5 -2
  3. package/esm/components/form.d.ts.map +1 -1
  4. package/esm/components/form.js +28 -6
  5. package/esm/components/form.js.map +1 -1
  6. package/esm/components/form.spec.js +207 -0
  7. package/esm/components/form.spec.js.map +1 -1
  8. package/esm/components/index.d.ts +1 -0
  9. package/esm/components/index.d.ts.map +1 -1
  10. package/esm/components/index.js +1 -0
  11. package/esm/components/index.js.map +1 -1
  12. package/esm/components/markdown/index.d.ts +5 -0
  13. package/esm/components/markdown/index.d.ts.map +1 -0
  14. package/esm/components/markdown/index.js +5 -0
  15. package/esm/components/markdown/index.js.map +1 -0
  16. package/esm/components/markdown/markdown-display.d.ts +19 -0
  17. package/esm/components/markdown/markdown-display.d.ts.map +1 -0
  18. package/esm/components/markdown/markdown-display.js +149 -0
  19. package/esm/components/markdown/markdown-display.js.map +1 -0
  20. package/esm/components/markdown/markdown-display.spec.d.ts +2 -0
  21. package/esm/components/markdown/markdown-display.spec.d.ts.map +1 -0
  22. package/esm/components/markdown/markdown-display.spec.js +191 -0
  23. package/esm/components/markdown/markdown-display.spec.js.map +1 -0
  24. package/esm/components/markdown/markdown-editor.d.ts +25 -0
  25. package/esm/components/markdown/markdown-editor.d.ts.map +1 -0
  26. package/esm/components/markdown/markdown-editor.js +113 -0
  27. package/esm/components/markdown/markdown-editor.js.map +1 -0
  28. package/esm/components/markdown/markdown-editor.spec.d.ts +2 -0
  29. package/esm/components/markdown/markdown-editor.spec.d.ts.map +1 -0
  30. package/esm/components/markdown/markdown-editor.spec.js +111 -0
  31. package/esm/components/markdown/markdown-editor.spec.js.map +1 -0
  32. package/esm/components/markdown/markdown-input.d.ts +29 -0
  33. package/esm/components/markdown/markdown-input.d.ts.map +1 -0
  34. package/esm/components/markdown/markdown-input.js +100 -0
  35. package/esm/components/markdown/markdown-input.js.map +1 -0
  36. package/esm/components/markdown/markdown-input.spec.d.ts +2 -0
  37. package/esm/components/markdown/markdown-input.spec.d.ts.map +1 -0
  38. package/esm/components/markdown/markdown-input.spec.js +215 -0
  39. package/esm/components/markdown/markdown-input.spec.js.map +1 -0
  40. package/esm/components/markdown/markdown-parser.d.ts +82 -0
  41. package/esm/components/markdown/markdown-parser.d.ts.map +1 -0
  42. package/esm/components/markdown/markdown-parser.js +274 -0
  43. package/esm/components/markdown/markdown-parser.js.map +1 -0
  44. package/esm/components/markdown/markdown-parser.spec.d.ts +2 -0
  45. package/esm/components/markdown/markdown-parser.spec.d.ts.map +1 -0
  46. package/esm/components/markdown/markdown-parser.spec.js +229 -0
  47. package/esm/components/markdown/markdown-parser.spec.js.map +1 -0
  48. package/esm/components/styles.d.ts +1 -0
  49. package/esm/components/styles.d.ts.map +1 -1
  50. package/esm/components/styles.js.map +1 -1
  51. package/esm/components/typography.d.ts.map +1 -1
  52. package/esm/components/typography.js +26 -14
  53. package/esm/components/typography.js.map +1 -1
  54. package/esm/services/css-variable-theme.d.ts +3 -0
  55. package/esm/services/css-variable-theme.d.ts.map +1 -1
  56. package/esm/services/css-variable-theme.js +3 -0
  57. package/esm/services/css-variable-theme.js.map +1 -1
  58. package/esm/services/css-variable-theme.spec.js +3 -0
  59. package/esm/services/css-variable-theme.spec.js.map +1 -1
  60. package/esm/services/default-dark-palette.d.ts +8 -0
  61. package/esm/services/default-dark-palette.d.ts.map +1 -0
  62. package/esm/services/default-dark-palette.js +56 -0
  63. package/esm/services/default-dark-palette.js.map +1 -0
  64. package/esm/services/default-dark-theme.d.ts +3 -0
  65. package/esm/services/default-dark-theme.d.ts.map +1 -1
  66. package/esm/services/default-dark-theme.js +7 -4
  67. package/esm/services/default-dark-theme.js.map +1 -1
  68. package/esm/services/default-light-theme.d.ts +3 -0
  69. package/esm/services/default-light-theme.d.ts.map +1 -1
  70. package/esm/services/default-light-theme.js +3 -0
  71. package/esm/services/default-light-theme.js.map +1 -1
  72. package/esm/services/index.d.ts +1 -0
  73. package/esm/services/index.d.ts.map +1 -1
  74. package/esm/services/index.js +1 -0
  75. package/esm/services/index.js.map +1 -1
  76. package/esm/services/theme-provider-service.d.ts +10 -1
  77. package/esm/services/theme-provider-service.d.ts.map +1 -1
  78. package/esm/services/theme-provider-service.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/components/form.spec.tsx +309 -0
  81. package/src/components/form.tsx +31 -8
  82. package/src/components/index.ts +1 -0
  83. package/src/components/markdown/index.ts +4 -0
  84. package/src/components/markdown/markdown-display.spec.tsx +243 -0
  85. package/src/components/markdown/markdown-display.tsx +202 -0
  86. package/src/components/markdown/markdown-editor.spec.tsx +142 -0
  87. package/src/components/markdown/markdown-editor.tsx +167 -0
  88. package/src/components/markdown/markdown-input.spec.tsx +274 -0
  89. package/src/components/markdown/markdown-input.tsx +143 -0
  90. package/src/components/markdown/markdown-parser.spec.ts +258 -0
  91. package/src/components/markdown/markdown-parser.ts +333 -0
  92. package/src/components/styles.tsx +1 -0
  93. package/src/components/typography.tsx +28 -15
  94. package/src/services/css-variable-theme.spec.ts +3 -0
  95. package/src/services/css-variable-theme.ts +3 -0
  96. package/src/services/default-dark-palette.ts +57 -0
  97. package/src/services/default-dark-theme.ts +7 -4
  98. package/src/services/default-light-theme.ts +3 -0
  99. package/src/services/index.ts +1 -0
  100. package/src/services/theme-provider-service.ts +7 -1
@@ -0,0 +1,274 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { createComponent, initializeShadeRoot } from '@furystack/shades'
3
+ import { sleepAsync, usingAsync } from '@furystack/utils'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { MarkdownInput } from './markdown-input.js'
6
+
7
+ describe('MarkdownInput', () => {
8
+ beforeEach(() => {
9
+ document.body.innerHTML = '<div id="root"></div>'
10
+ })
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = ''
14
+ vi.restoreAllMocks()
15
+ })
16
+
17
+ it('should render with shadow DOM', async () => {
18
+ await usingAsync(new Injector(), async (injector) => {
19
+ const rootElement = document.getElementById('root') as HTMLDivElement
20
+
21
+ initializeShadeRoot({
22
+ injector,
23
+ rootElement,
24
+ jsxElement: <MarkdownInput value="" />,
25
+ })
26
+
27
+ await sleepAsync(50)
28
+
29
+ const el = document.querySelector('shade-markdown-input')
30
+ expect(el).not.toBeNull()
31
+ })
32
+ })
33
+
34
+ it('should render a textarea with the given value', async () => {
35
+ await usingAsync(new Injector(), async (injector) => {
36
+ const rootElement = document.getElementById('root') as HTMLDivElement
37
+
38
+ initializeShadeRoot({
39
+ injector,
40
+ rootElement,
41
+ jsxElement: <MarkdownInput value="# Hello" />,
42
+ })
43
+
44
+ await sleepAsync(50)
45
+
46
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
47
+ expect(textarea).not.toBeNull()
48
+ expect(textarea.value).toBe('# Hello')
49
+ })
50
+ })
51
+
52
+ it('should render the label title', async () => {
53
+ await usingAsync(new Injector(), async (injector) => {
54
+ const rootElement = document.getElementById('root') as HTMLDivElement
55
+
56
+ initializeShadeRoot({
57
+ injector,
58
+ rootElement,
59
+ jsxElement: <MarkdownInput value="" labelTitle="Markdown Content" />,
60
+ })
61
+
62
+ await sleepAsync(50)
63
+
64
+ const label = document.querySelector('shade-markdown-input label')
65
+ expect(label?.textContent).toContain('Markdown Content')
66
+ })
67
+ })
68
+
69
+ it('should set placeholder on textarea', async () => {
70
+ await usingAsync(new Injector(), async (injector) => {
71
+ const rootElement = document.getElementById('root') as HTMLDivElement
72
+
73
+ initializeShadeRoot({
74
+ injector,
75
+ rootElement,
76
+ jsxElement: <MarkdownInput value="" placeholder="Type markdown..." />,
77
+ })
78
+
79
+ await sleepAsync(50)
80
+
81
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
82
+ expect(textarea.placeholder).toBe('Type markdown...')
83
+ })
84
+ })
85
+
86
+ it('should set data-disabled when disabled', async () => {
87
+ await usingAsync(new Injector(), async (injector) => {
88
+ const rootElement = document.getElementById('root') as HTMLDivElement
89
+
90
+ initializeShadeRoot({
91
+ injector,
92
+ rootElement,
93
+ jsxElement: <MarkdownInput value="" disabled />,
94
+ })
95
+
96
+ await sleepAsync(50)
97
+
98
+ const wrapper = document.querySelector('shade-markdown-input') as HTMLElement
99
+ expect(wrapper.hasAttribute('data-disabled')).toBe(true)
100
+
101
+ const textarea = wrapper.querySelector('textarea') as HTMLTextAreaElement
102
+ expect(textarea.disabled).toBe(true)
103
+ })
104
+ })
105
+
106
+ it('should set readOnly on textarea', async () => {
107
+ await usingAsync(new Injector(), async (injector) => {
108
+ const rootElement = document.getElementById('root') as HTMLDivElement
109
+
110
+ initializeShadeRoot({
111
+ injector,
112
+ rootElement,
113
+ jsxElement: <MarkdownInput value="" readOnly />,
114
+ })
115
+
116
+ await sleepAsync(50)
117
+
118
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
119
+ expect(textarea.readOnly).toBe(true)
120
+ })
121
+ })
122
+
123
+ it('should call onValueChange on input event', async () => {
124
+ await usingAsync(new Injector(), async (injector) => {
125
+ const rootElement = document.getElementById('root') as HTMLDivElement
126
+ const onValueChange = vi.fn()
127
+
128
+ initializeShadeRoot({
129
+ injector,
130
+ rootElement,
131
+ jsxElement: <MarkdownInput value="" onValueChange={onValueChange} />,
132
+ })
133
+
134
+ await sleepAsync(50)
135
+
136
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
137
+ textarea.value = '# New content'
138
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
139
+
140
+ await sleepAsync(50)
141
+
142
+ expect(onValueChange).toHaveBeenCalledWith('# New content')
143
+ })
144
+ })
145
+
146
+ it('should use custom rows prop', async () => {
147
+ await usingAsync(new Injector(), async (injector) => {
148
+ const rootElement = document.getElementById('root') as HTMLDivElement
149
+
150
+ initializeShadeRoot({
151
+ injector,
152
+ rootElement,
153
+ jsxElement: <MarkdownInput value="" rows={20} />,
154
+ })
155
+
156
+ await sleepAsync(50)
157
+
158
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
159
+ expect(textarea.rows).toBe(20)
160
+ })
161
+ })
162
+
163
+ describe('image paste', () => {
164
+ const createPasteEvent = (items: Array<{ type: string; file: File | null }>) => {
165
+ const pasteEvent = new Event('paste', { bubbles: true, cancelable: true })
166
+ Object.defineProperty(pasteEvent, 'clipboardData', {
167
+ value: {
168
+ items: Object.assign(
169
+ items.map((item) => ({
170
+ type: item.type,
171
+ getAsFile: () => item.file,
172
+ })),
173
+ { length: items.length },
174
+ ),
175
+ },
176
+ })
177
+ return pasteEvent
178
+ }
179
+
180
+ it('should inline a pasted image as base64 Markdown', async () => {
181
+ const originalFileReader = globalThis.FileReader
182
+ try {
183
+ const fakeBase64 = 'data:image/png;base64,dGVzdA=='
184
+ globalThis.FileReader = class {
185
+ result: string | null = fakeBase64
186
+ onload: (() => void) | null = null
187
+ onerror: (() => void) | null = null
188
+ public readAsDataURL() {
189
+ queueMicrotask(() => this.onload?.())
190
+ }
191
+ } as unknown as typeof FileReader
192
+
193
+ await usingAsync(new Injector(), async (injector) => {
194
+ const rootElement = document.getElementById('root') as HTMLDivElement
195
+ const onValueChange = vi.fn()
196
+
197
+ initializeShadeRoot({
198
+ injector,
199
+ rootElement,
200
+ jsxElement: <MarkdownInput value="Hello " onValueChange={onValueChange} />,
201
+ })
202
+
203
+ await sleepAsync(50)
204
+
205
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
206
+ textarea.selectionStart = 6
207
+ textarea.selectionEnd = 6
208
+
209
+ const file = new File(['png-data'], 'test.png', { type: 'image/png' })
210
+ const pasteEvent = createPasteEvent([{ type: 'image/png', file }])
211
+ textarea.dispatchEvent(pasteEvent)
212
+
213
+ await sleepAsync(100)
214
+
215
+ expect(onValueChange).toHaveBeenCalledOnce()
216
+ const result = onValueChange.mock.calls[0][0] as string
217
+ expect(result).toContain('![pasted image](data:')
218
+ expect(result.startsWith('Hello ')).toBe(true)
219
+ })
220
+ } finally {
221
+ globalThis.FileReader = originalFileReader
222
+ }
223
+ })
224
+
225
+ it('should ignore pasted images exceeding maxImageSizeBytes', async () => {
226
+ await usingAsync(new Injector(), async (injector) => {
227
+ const rootElement = document.getElementById('root') as HTMLDivElement
228
+ const onValueChange = vi.fn()
229
+
230
+ initializeShadeRoot({
231
+ injector,
232
+ rootElement,
233
+ jsxElement: <MarkdownInput value="" onValueChange={onValueChange} maxImageSizeBytes={5} />,
234
+ })
235
+
236
+ await sleepAsync(50)
237
+
238
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
239
+ const file = new File(['this-is-larger-than-5-bytes'], 'big.png', { type: 'image/png' })
240
+ const pasteEvent = createPasteEvent([{ type: 'image/png', file }])
241
+ textarea.dispatchEvent(pasteEvent)
242
+
243
+ await sleepAsync(100)
244
+
245
+ expect(onValueChange).not.toHaveBeenCalled()
246
+ })
247
+ })
248
+
249
+ it('should not interfere with non-image paste', async () => {
250
+ await usingAsync(new Injector(), async (injector) => {
251
+ const rootElement = document.getElementById('root') as HTMLDivElement
252
+ const onValueChange = vi.fn()
253
+
254
+ initializeShadeRoot({
255
+ injector,
256
+ rootElement,
257
+ jsxElement: <MarkdownInput value="" onValueChange={onValueChange} />,
258
+ })
259
+
260
+ await sleepAsync(50)
261
+
262
+ const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
263
+ const file = new File(['text content'], 'note.txt', { type: 'text/plain' })
264
+ const pasteEvent = createPasteEvent([{ type: 'text/plain', file }])
265
+ const wasDefaultPrevented = !textarea.dispatchEvent(pasteEvent)
266
+
267
+ await sleepAsync(100)
268
+
269
+ expect(wasDefaultPrevented).toBe(false)
270
+ expect(onValueChange).not.toHaveBeenCalled()
271
+ })
272
+ })
273
+ })
274
+ })
@@ -0,0 +1,143 @@
1
+ import { Shade, createComponent } from '@furystack/shades'
2
+ import { cssVariableTheme } from '../../services/css-variable-theme.js'
3
+
4
+ const DEFAULT_MAX_IMAGE_SIZE = 256 * 1024
5
+
6
+ export type MarkdownInputProps = {
7
+ /** The current Markdown string */
8
+ value: string
9
+ /** Called when the value changes */
10
+ onValueChange?: (newValue: string) => void
11
+ /** Maximum image file size in bytes for base64 paste. Defaults to 256KB. */
12
+ maxImageSizeBytes?: number
13
+ /** Whether the textarea is read-only */
14
+ readOnly?: boolean
15
+ /** Whether the textarea is disabled */
16
+ disabled?: boolean
17
+ /** Placeholder text */
18
+ placeholder?: string
19
+ /** Label shown above the textarea */
20
+ labelTitle?: string
21
+ /** Number of visible text rows */
22
+ rows?: number
23
+ }
24
+
25
+ /**
26
+ * Markdown text input with base64 image paste support.
27
+ * When the user pastes an image below the configured size limit,
28
+ * it is inlined as a `![pasted image](data:...)` Markdown image.
29
+ */
30
+ export const MarkdownInput = Shade<MarkdownInputProps>({
31
+ shadowDomName: 'shade-markdown-input',
32
+ css: {
33
+ display: 'block',
34
+ marginBottom: '1em',
35
+
36
+ '& label': {
37
+ display: 'flex',
38
+ flexDirection: 'column',
39
+ alignItems: 'flex-start',
40
+ fontSize: cssVariableTheme.typography.fontSize.xs,
41
+ color: cssVariableTheme.text.secondary,
42
+ padding: '1em',
43
+ borderRadius: cssVariableTheme.shape.borderRadius.md,
44
+ border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
45
+ transition: `color ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.default}`,
46
+ },
47
+
48
+ '&[data-disabled] label': {
49
+ color: cssVariableTheme.text.disabled,
50
+ },
51
+
52
+ '&:focus-within label': {
53
+ color: cssVariableTheme.palette.primary.main,
54
+ },
55
+
56
+ '& textarea': {
57
+ border: 'none',
58
+ backgroundColor: 'transparent',
59
+ outline: 'none',
60
+ fontSize: cssVariableTheme.typography.fontSize.sm,
61
+ fontFamily: 'monospace',
62
+ width: '100%',
63
+ resize: 'vertical',
64
+ color: cssVariableTheme.text.primary,
65
+ boxShadow: '0px 0px 0px rgba(128,128,128,0.1)',
66
+ transition: `box-shadow ${cssVariableTheme.transitions.duration.normal} ease`,
67
+ lineHeight: cssVariableTheme.typography.lineHeight.relaxed,
68
+ padding: `${cssVariableTheme.spacing.sm} 0`,
69
+ },
70
+
71
+ '&:focus-within textarea': {
72
+ boxShadow: `0px 3px 0px ${cssVariableTheme.palette.primary.main}`,
73
+ },
74
+ },
75
+ render: ({ props, useHostProps, useRef }) => {
76
+ const maxSize = props.maxImageSizeBytes ?? DEFAULT_MAX_IMAGE_SIZE
77
+ const textareaRef = useRef<HTMLTextAreaElement>('textarea')
78
+
79
+ useHostProps({
80
+ 'data-disabled': props.disabled ? '' : undefined,
81
+ })
82
+
83
+ const handleInput = (ev: Event) => {
84
+ const target = ev.target as HTMLTextAreaElement
85
+ props.onValueChange?.(target.value)
86
+ }
87
+
88
+ const handlePaste = (ev: ClipboardEvent) => {
89
+ const items = ev.clipboardData?.items
90
+ if (!items) return
91
+
92
+ for (let i = 0; i < items.length; i++) {
93
+ const item = items[i]
94
+ if (item.type.startsWith('image/')) {
95
+ const file = item.getAsFile()
96
+ if (!file || file.size > maxSize) continue
97
+
98
+ ev.preventDefault()
99
+
100
+ const reader = new FileReader()
101
+ reader.onload = () => {
102
+ const base64 = reader.result as string
103
+ const textarea = textareaRef.current
104
+ if (!textarea) return
105
+
106
+ const start = textarea.selectionStart
107
+ const end = textarea.selectionEnd
108
+ const before = textarea.value.slice(0, start)
109
+ const after = textarea.value.slice(end)
110
+ const imageMarkdown = `![pasted image](${base64})`
111
+ const newValue = before + imageMarkdown + after
112
+
113
+ textarea.value = newValue
114
+ const cursorPos = start + imageMarkdown.length
115
+ textarea.setSelectionRange(cursorPos, cursorPos)
116
+ props.onValueChange?.(newValue)
117
+ }
118
+ reader.onerror = () => {
119
+ console.warn('Failed to read pasted image file')
120
+ }
121
+ reader.readAsDataURL(file)
122
+ return
123
+ }
124
+ }
125
+ }
126
+
127
+ return (
128
+ <label>
129
+ {props.labelTitle ? <span>{props.labelTitle}</span> : null}
130
+ <textarea
131
+ ref={textareaRef}
132
+ value={props.value}
133
+ oninput={handleInput}
134
+ onpaste={handlePaste}
135
+ readOnly={props.readOnly}
136
+ disabled={props.disabled}
137
+ placeholder={props.placeholder}
138
+ rows={props.rows ?? 10}
139
+ />
140
+ </label>
141
+ )
142
+ },
143
+ })
@@ -0,0 +1,258 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseInline, parseMarkdown, toggleCheckbox } from './markdown-parser.js'
3
+
4
+ describe('parseInline', () => {
5
+ it('should parse plain text', () => {
6
+ const result = parseInline('hello world')
7
+ expect(result).toEqual([{ type: 'text', content: 'hello world' }])
8
+ })
9
+
10
+ it('should parse inline code', () => {
11
+ const result = parseInline('use `const x = 1` here')
12
+ expect(result).toEqual([
13
+ { type: 'text', content: 'use ' },
14
+ { type: 'code', content: 'const x = 1' },
15
+ { type: 'text', content: ' here' },
16
+ ])
17
+ })
18
+
19
+ it('should parse bold with **', () => {
20
+ const result = parseInline('this is **bold** text')
21
+ expect(result).toEqual([
22
+ { type: 'text', content: 'this is ' },
23
+ { type: 'bold', children: [{ type: 'text', content: 'bold' }] },
24
+ { type: 'text', content: ' text' },
25
+ ])
26
+ })
27
+
28
+ it('should parse italic with *', () => {
29
+ const result = parseInline('this is *italic* text')
30
+ expect(result).toEqual([
31
+ { type: 'text', content: 'this is ' },
32
+ { type: 'italic', children: [{ type: 'text', content: 'italic' }] },
33
+ { type: 'text', content: ' text' },
34
+ ])
35
+ })
36
+
37
+ it('should parse bold+italic with ***', () => {
38
+ const result = parseInline('this is ***bold italic*** text')
39
+ expect(result).toEqual([
40
+ { type: 'text', content: 'this is ' },
41
+ { type: 'bold', children: [{ type: 'italic', children: [{ type: 'text', content: 'bold italic' }] }] },
42
+ { type: 'text', content: ' text' },
43
+ ])
44
+ })
45
+
46
+ it('should parse links', () => {
47
+ const result = parseInline('click [here](https://example.com) now')
48
+ expect(result).toEqual([
49
+ { type: 'text', content: 'click ' },
50
+ { type: 'link', href: 'https://example.com', children: [{ type: 'text', content: 'here' }] },
51
+ { type: 'text', content: ' now' },
52
+ ])
53
+ })
54
+
55
+ it('should parse images', () => {
56
+ const result = parseInline('see ![alt text](image.png) here')
57
+ expect(result).toEqual([
58
+ { type: 'text', content: 'see ' },
59
+ { type: 'image', src: 'image.png', alt: 'alt text' },
60
+ { type: 'text', content: ' here' },
61
+ ])
62
+ })
63
+
64
+ it('should parse nested bold in link', () => {
65
+ const result = parseInline('[**bold link**](url)')
66
+ expect(result).toEqual([
67
+ {
68
+ type: 'link',
69
+ href: 'url',
70
+ children: [{ type: 'bold', children: [{ type: 'text', content: 'bold link' }] }],
71
+ },
72
+ ])
73
+ })
74
+
75
+ it('should handle empty string', () => {
76
+ expect(parseInline('')).toEqual([])
77
+ })
78
+ })
79
+
80
+ describe('parseMarkdown', () => {
81
+ it('should return empty array for empty input', () => {
82
+ expect(parseMarkdown('')).toEqual([])
83
+ })
84
+
85
+ it('should parse headings level 1–6', () => {
86
+ const md = '# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6'
87
+ const result = parseMarkdown(md)
88
+ expect(result).toHaveLength(6)
89
+ for (let i = 0; i < 6; i++) {
90
+ expect(result[i]).toMatchObject({ type: 'heading', level: i + 1 })
91
+ }
92
+ })
93
+
94
+ it('should parse a paragraph', () => {
95
+ const result = parseMarkdown('Hello world\nthis is a paragraph.')
96
+ expect(result).toEqual([
97
+ {
98
+ type: 'paragraph',
99
+ children: [{ type: 'text', content: 'Hello world this is a paragraph.' }],
100
+ },
101
+ ])
102
+ })
103
+
104
+ it('should split paragraphs on blank lines', () => {
105
+ const result = parseMarkdown('First paragraph.\n\nSecond paragraph.')
106
+ expect(result).toHaveLength(2)
107
+ expect(result[0]).toMatchObject({ type: 'paragraph' })
108
+ expect(result[1]).toMatchObject({ type: 'paragraph' })
109
+ })
110
+
111
+ it('should parse fenced code blocks', () => {
112
+ const md = '```typescript\nconst x = 1\nconsole.log(x)\n```'
113
+ const result = parseMarkdown(md)
114
+ expect(result).toEqual([
115
+ {
116
+ type: 'codeBlock',
117
+ language: 'typescript',
118
+ content: 'const x = 1\nconsole.log(x)',
119
+ },
120
+ ])
121
+ })
122
+
123
+ it('should parse fenced code blocks without language', () => {
124
+ const md = '```\nhello\n```'
125
+ const result = parseMarkdown(md)
126
+ expect(result).toEqual([
127
+ {
128
+ type: 'codeBlock',
129
+ language: undefined,
130
+ content: 'hello',
131
+ },
132
+ ])
133
+ })
134
+
135
+ it('should parse horizontal rules', () => {
136
+ for (const rule of ['---', '***', '___', '----', '****']) {
137
+ const result = parseMarkdown(rule)
138
+ expect(result).toEqual([{ type: 'horizontalRule' }])
139
+ }
140
+ })
141
+
142
+ it('should parse unordered lists', () => {
143
+ const md = '- Item 1\n- Item 2\n- Item 3'
144
+ const result = parseMarkdown(md)
145
+ expect(result).toHaveLength(1)
146
+ expect(result[0]).toMatchObject({ type: 'list', ordered: false })
147
+ const list = result[0] as { type: 'list'; items: unknown[] }
148
+ expect(list.items).toHaveLength(3)
149
+ })
150
+
151
+ it('should parse ordered lists', () => {
152
+ const md = '1. First\n2. Second\n3. Third'
153
+ const result = parseMarkdown(md)
154
+ expect(result).toHaveLength(1)
155
+ expect(result[0]).toMatchObject({ type: 'list', ordered: true })
156
+ const list = result[0] as { type: 'list'; items: unknown[] }
157
+ expect(list.items).toHaveLength(3)
158
+ })
159
+
160
+ it('should parse checkboxes in unordered lists', () => {
161
+ const md = '- [x] Done task\n- [ ] Todo task\n- Regular item'
162
+ const result = parseMarkdown(md)
163
+ expect(result).toHaveLength(1)
164
+ const list = result[0] as { type: 'list'; items: Array<{ checkbox?: string }> }
165
+ expect(list.items[0].checkbox).toBe('checked')
166
+ expect(list.items[1].checkbox).toBe('unchecked')
167
+ expect(list.items[2].checkbox).toBeUndefined()
168
+ })
169
+
170
+ it('should track sourceLineIndex on list items', () => {
171
+ const md = '- [x] Done\n- [ ] Todo'
172
+ const result = parseMarkdown(md)
173
+ const list = result[0] as { type: 'list'; items: Array<{ sourceLineIndex: number }> }
174
+ expect(list.items[0].sourceLineIndex).toBe(0)
175
+ expect(list.items[1].sourceLineIndex).toBe(1)
176
+ })
177
+
178
+ it('should parse blockquotes', () => {
179
+ const md = '> This is a quote\n> Second line'
180
+ const result = parseMarkdown(md)
181
+ expect(result).toHaveLength(1)
182
+ expect(result[0]).toMatchObject({ type: 'blockquote' })
183
+ const bq = result[0] as { type: 'blockquote'; children: unknown[] }
184
+ expect(bq.children).toHaveLength(1)
185
+ expect(bq.children[0]).toMatchObject({ type: 'paragraph' })
186
+ })
187
+
188
+ it('should parse a complex document', () => {
189
+ const md = [
190
+ '# Title',
191
+ '',
192
+ 'A paragraph with **bold** and *italic*.',
193
+ '',
194
+ '## List Section',
195
+ '',
196
+ '- [x] Task done',
197
+ '- [ ] Task pending',
198
+ '',
199
+ '> A blockquote',
200
+ '',
201
+ '---',
202
+ '',
203
+ '```js',
204
+ 'console.log("hi")',
205
+ '```',
206
+ ].join('\n')
207
+
208
+ const result = parseMarkdown(md)
209
+ expect(result[0]).toMatchObject({ type: 'heading', level: 1 })
210
+ expect(result[1]).toMatchObject({ type: 'paragraph' })
211
+ expect(result[2]).toMatchObject({ type: 'heading', level: 2 })
212
+ expect(result[3]).toMatchObject({ type: 'list', ordered: false })
213
+ expect(result[4]).toMatchObject({ type: 'blockquote' })
214
+ expect(result[5]).toMatchObject({ type: 'horizontalRule' })
215
+ expect(result[6]).toMatchObject({ type: 'codeBlock', language: 'js' })
216
+ })
217
+ })
218
+
219
+ describe('toggleCheckbox', () => {
220
+ it('should toggle an unchecked checkbox to checked', () => {
221
+ const source = '- [ ] Todo item'
222
+ const result = toggleCheckbox(source, 0)
223
+ expect(result).toBe('- [x] Todo item')
224
+ })
225
+
226
+ it('should toggle a checked checkbox to unchecked', () => {
227
+ const source = '- [x] Done item'
228
+ const result = toggleCheckbox(source, 0)
229
+ expect(result).toBe('- [ ] Done item')
230
+ })
231
+
232
+ it('should toggle the correct line in a multi-line document', () => {
233
+ const source = '- [x] Done\n- [ ] Todo\n- [ ] Another'
234
+ const result = toggleCheckbox(source, 1)
235
+ expect(result).toBe('- [x] Done\n- [x] Todo\n- [ ] Another')
236
+ })
237
+
238
+ it('should return original string for out-of-bounds index', () => {
239
+ const source = '- [ ] Todo'
240
+ expect(toggleCheckbox(source, 5)).toBe(source)
241
+ expect(toggleCheckbox(source, -1)).toBe(source)
242
+ })
243
+
244
+ it('should return original string for non-checkbox line', () => {
245
+ const source = 'Regular text'
246
+ expect(toggleCheckbox(source, 0)).toBe(source)
247
+ })
248
+
249
+ it('should not toggle [ ] that appears outside a list item', () => {
250
+ const source = 'This line has [ ] in it but is not a list item'
251
+ expect(toggleCheckbox(source, 0)).toBe(source)
252
+ })
253
+
254
+ it('should toggle checkboxes with * list marker', () => {
255
+ const source = '* [ ] Star item'
256
+ expect(toggleCheckbox(source, 0)).toBe('* [x] Star item')
257
+ })
258
+ })