@markuplint/markdown-parser 5.0.0-alpha.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.
@@ -0,0 +1,747 @@
1
+ import { describe, test, expect } from 'vitest';
2
+
3
+ import { nodeListToDebugMaps } from '@markuplint/parser-utils';
4
+
5
+ import { parser } from './parser.js';
6
+ import { getLineAndColumn } from './markdown-aware-parser.js';
7
+
8
+ function parse(code: string) {
9
+ return parser.parse(code);
10
+ }
11
+
12
+ describe('MarkdownParser', () => {
13
+ describe('Markdown elements', () => {
14
+ test('heading', () => {
15
+ const doc = parse('# Heading');
16
+ const maps = nodeListToDebugMaps(doc.nodeList);
17
+ expect(maps).toStrictEqual(['[1:1]>[1:10](0,9)h1: #\u2423Heading', '[1:3]>[1:10](2,9)#text: Heading']);
18
+ });
19
+
20
+ test('heading depth', () => {
21
+ const doc = parse('### Heading 3');
22
+ const maps = nodeListToDebugMaps(doc.nodeList);
23
+ expect(maps).toStrictEqual([
24
+ '[1:1]>[1:14](0,13)h3: ###\u2423Heading\u24233',
25
+ '[1:5]>[1:14](4,13)#text: Heading\u24233',
26
+ ]);
27
+ });
28
+
29
+ test('heading h6 (max depth)', () => {
30
+ const doc = parse('###### Heading 6');
31
+ const maps = nodeListToDebugMaps(doc.nodeList);
32
+ expect(maps).toStrictEqual([
33
+ '[1:1]>[1:17](0,16)h6: ######\u2423Heading\u24236',
34
+ '[1:8]>[1:17](7,16)#text: Heading\u24236',
35
+ ]);
36
+ });
37
+
38
+ test('paragraph', () => {
39
+ const doc = parse('Some paragraph text.');
40
+ const maps = nodeListToDebugMaps(doc.nodeList);
41
+ expect(maps).toStrictEqual([
42
+ '[1:1]>[1:21](0,20)p: Some\u2423paragraph\u2423text.',
43
+ '[1:1]>[1:21](0,20)#text: Some\u2423paragraph\u2423text.',
44
+ ]);
45
+ });
46
+
47
+ test('emphasis', () => {
48
+ const doc = parse('*emphasized*');
49
+ const maps = nodeListToDebugMaps(doc.nodeList);
50
+ expect(maps).toStrictEqual([
51
+ '[1:1]>[1:13](0,12)p: *emphasized*',
52
+ '[1:1]>[1:13](0,12)em: *emphasized*',
53
+ '[1:2]>[1:12](1,11)#text: emphasized',
54
+ ]);
55
+ });
56
+
57
+ test('strong', () => {
58
+ const doc = parse('**bold**');
59
+ const maps = nodeListToDebugMaps(doc.nodeList);
60
+ expect(maps).toStrictEqual([
61
+ '[1:1]>[1:9](0,8)p: **bold**',
62
+ '[1:1]>[1:9](0,8)strong: **bold**',
63
+ '[1:3]>[1:7](2,6)#text: bold',
64
+ ]);
65
+ });
66
+
67
+ test('link', () => {
68
+ const doc = parse('[link text](https://example.com)');
69
+ const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
70
+ expect(startTag).toBeDefined();
71
+ const href = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'href');
72
+ expect(href).toBeDefined();
73
+ expect(href!.value.raw).toBe('https://example.com');
74
+ });
75
+
76
+ test('link with title', () => {
77
+ const doc = parse('[link](https://example.com "title text")');
78
+ const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
79
+ expect(startTag).toBeDefined();
80
+ const title = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'title');
81
+ expect(title).toBeDefined();
82
+ expect(title!.value.raw).toBe('title text');
83
+ });
84
+
85
+ test('link without title does NOT have title attribute', () => {
86
+ const doc = parse('[link](https://example.com)');
87
+ const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
88
+ expect(startTag).toBeDefined();
89
+ const title = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'title');
90
+ expect(title).toBeUndefined();
91
+ });
92
+
93
+ test('image', () => {
94
+ const doc = parse('![alt text](image.png)');
95
+ const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
96
+ expect(startTag).toBeDefined();
97
+ const src = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'src');
98
+ expect(src).toBeDefined();
99
+ expect(src!.value.raw).toBe('image.png');
100
+ const alt = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'alt');
101
+ expect(alt).toBeDefined();
102
+ expect(alt!.value.raw).toBe('alt text');
103
+ });
104
+
105
+ test('image with empty alt', () => {
106
+ const doc = parse('![](image.png)');
107
+ const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
108
+ expect(startTag).toBeDefined();
109
+ const alt = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'alt');
110
+ expect(alt).toBeDefined();
111
+ expect(alt!.value.raw).toBe('');
112
+ });
113
+
114
+ test('image without title does NOT have title attribute', () => {
115
+ const doc = parse('![alt](image.png)');
116
+ const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
117
+ expect(startTag).toBeDefined();
118
+ const title = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'title');
119
+ expect(title).toBeUndefined();
120
+ // Verify exactly 2 attributes: src and alt
121
+ expect(startTag!.attributes.length).toBe(2);
122
+ });
123
+
124
+ test('image with title has title attribute', () => {
125
+ const doc = parse('![alt](image.png "my title")');
126
+ const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
127
+ expect(startTag).toBeDefined();
128
+ const title = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'title');
129
+ expect(title).toBeDefined();
130
+ expect(title!.value.raw).toBe('my title');
131
+ // Verify exactly 3 attributes: src, alt, title
132
+ expect(startTag!.attributes.length).toBe(3);
133
+ });
134
+
135
+ test('unordered list', () => {
136
+ const doc = parse('- item 1\n- item 2\n');
137
+ const ul = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'ul');
138
+ expect(ul).toBeDefined();
139
+ const lis = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'li');
140
+ expect(lis.length).toBe(2);
141
+ });
142
+
143
+ test('ordered list', () => {
144
+ const doc = parse('1. item 1\n2. item 2\n');
145
+ const ol = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'ol');
146
+ expect(ol).toBeDefined();
147
+ });
148
+
149
+ test('ordered list with custom start', () => {
150
+ const doc = parse('5. item 5\n6. item 6\n');
151
+ const ol = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'ol');
152
+ expect(ol).toBeDefined();
153
+ const start = ol!.attributes.find(a => a.type === 'attr' && a.name.raw === 'start');
154
+ expect(start).toBeDefined();
155
+ expect(start!.value.raw).toBe('5');
156
+ });
157
+
158
+ test('ordered list starting at 1 does NOT have start attribute', () => {
159
+ const doc = parse('1. first\n2. second\n');
160
+ const ol = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'ol');
161
+ expect(ol).toBeDefined();
162
+ const start = ol!.attributes.find(a => a.type === 'attr' && a.name.raw === 'start');
163
+ expect(start).toBeUndefined();
164
+ });
165
+
166
+ test('blockquote', () => {
167
+ const doc = parse('> quoted text');
168
+ const maps = nodeListToDebugMaps(doc.nodeList);
169
+ expect(maps).toStrictEqual([
170
+ '[1:1]>[1:14](0,13)blockquote: >␣quoted␣text',
171
+ '[1:3]>[1:14](2,13)p: quoted␣text',
172
+ '[1:3]>[1:14](2,13)#text: quoted␣text',
173
+ ]);
174
+ });
175
+
176
+ test('thematic break', () => {
177
+ const doc = parse('---\n');
178
+ const maps = nodeListToDebugMaps(doc.nodeList);
179
+ expect(maps).toStrictEqual(['[1:1]>[1:4](0,3)hr: ---']);
180
+ });
181
+
182
+ test('inline code', () => {
183
+ const doc = parse('`code here`');
184
+ const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
185
+ expect(code).toBeDefined();
186
+ expect(code!.childNodes.length).toBe(1);
187
+ expect(code!.childNodes[0].type).toBe('text');
188
+ expect(code!.childNodes[0].raw).toBe('code here');
189
+ });
190
+
191
+ test('inline code with double backticks', () => {
192
+ // remark parses `` code `` as inlineCode with value "code"
193
+ // The remaining " here ``" is a separate text node
194
+ const doc = parse('`` code `` here ``');
195
+ const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
196
+ expect(code).toBeDefined();
197
+ expect(code!.childNodes.length).toBe(1);
198
+ expect(code!.childNodes[0].raw).toBe('code');
199
+ });
200
+
201
+ test('code block becomes pre>code elements', () => {
202
+ const doc = parse('```html\n<div>not parsed</div>\n```\n');
203
+ const pre = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'pre');
204
+ expect(pre).toBeDefined();
205
+ const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
206
+ expect(code).toBeDefined();
207
+ const langAttr = code!.attributes.find(a => a.type === 'attr' && a.name.raw === 'class');
208
+ expect(langAttr).toBeDefined();
209
+ expect(langAttr!.value.raw).toBe('language-html');
210
+ });
211
+
212
+ test('code block without language', () => {
213
+ const doc = parse('```\nsome code\n```\n');
214
+ const pre = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'pre');
215
+ expect(pre).toBeDefined();
216
+ const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
217
+ expect(code).toBeDefined();
218
+ expect(code!.attributes.length).toBe(0);
219
+ });
220
+
221
+ test('code block text content is preserved', () => {
222
+ const doc = parse('```js\nconst x = 1;\n```\n');
223
+ const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
224
+ expect(code).toBeDefined();
225
+ expect(code!.childNodes.length).toBe(1);
226
+ expect(code!.childNodes[0].type).toBe('text');
227
+ expect(code!.childNodes[0].raw).toBe('const x = 1;');
228
+ });
229
+
230
+ test('code block with empty content has no text child', () => {
231
+ const doc = parse('```\n```\n');
232
+ const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
233
+ expect(code).toBeDefined();
234
+ expect(code!.childNodes.length).toBe(0);
235
+ });
236
+
237
+ test('hard line break (two trailing spaces) becomes <br>', () => {
238
+ const doc = parse('line one \nline two\n');
239
+ const br = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'br');
240
+ expect(br).toBeDefined();
241
+ });
242
+
243
+ test('nested markdown: bold link', () => {
244
+ const doc = parse('**[bold link](url)**');
245
+ const strong = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'strong');
246
+ expect(strong).toBeDefined();
247
+ const link = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
248
+ expect(link).toBeDefined();
249
+ const href = link!.attributes.find(a => a.type === 'attr' && a.name.raw === 'href');
250
+ expect(href!.value.raw).toBe('url');
251
+ });
252
+ });
253
+
254
+ describe('Link and image references', () => {
255
+ test('linkReference resolves to <a> element', () => {
256
+ const doc = parse('[link text][ref]\n\n[ref]: https://example.com "Example"\n');
257
+ const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
258
+ expect(a).toBeDefined();
259
+ const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
260
+ expect(href).toBeDefined();
261
+ expect(href!.value.raw).toBe('https://example.com');
262
+ const title = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'title');
263
+ expect(title).toBeDefined();
264
+ expect(title!.value.raw).toBe('Example');
265
+ });
266
+
267
+ test('imageReference resolves to <img> element', () => {
268
+ const doc = parse('![alt text][img]\n\n[img]: image.png "Title"\n');
269
+ const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
270
+ expect(img).toBeDefined();
271
+ const src = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'src');
272
+ expect(src).toBeDefined();
273
+ expect(src!.value.raw).toBe('image.png');
274
+ const alt = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'alt');
275
+ expect(alt).toBeDefined();
276
+ expect(alt!.value.raw).toBe('alt text');
277
+ });
278
+
279
+ test('unresolved linkReference is treated as plain text by remark', () => {
280
+ // remark-parse does not produce linkReference nodes when definition is missing;
281
+ // it treats the syntax as literal text in a paragraph.
282
+ const doc = parse('[text][missing]\n');
283
+ const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
284
+ expect(p).toBeDefined();
285
+ const text = doc.nodeList.find(n => n?.type === 'text');
286
+ expect(text).toBeDefined();
287
+ expect(text!.raw).toContain('[text][missing]');
288
+ });
289
+
290
+ test('unresolved imageReference is treated as plain text by remark', () => {
291
+ const doc = parse('![alt][missing]\n');
292
+ const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
293
+ expect(p).toBeDefined();
294
+ const text = doc.nodeList.find(n => n?.type === 'text');
295
+ expect(text).toBeDefined();
296
+ expect(text!.raw).toContain('![alt][missing]');
297
+ });
298
+
299
+ test('shortcut linkReference [ref] resolves using identifier as label', () => {
300
+ const doc = parse('[example]\n\n[example]: https://example.com\n');
301
+ const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
302
+ expect(a).toBeDefined();
303
+ const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
304
+ expect(href).toBeDefined();
305
+ expect(href!.value.raw).toBe('https://example.com');
306
+ });
307
+
308
+ test('linkReference title from definition is passed to <a>', () => {
309
+ const doc = parse('[link text][ref]\n\n[ref]: https://example.com "Example"\n');
310
+ const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
311
+ expect(a).toBeDefined();
312
+ expect(a!.attributes.length).toBe(2);
313
+ const title = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'title');
314
+ expect(title).toBeDefined();
315
+ expect(title!.value.raw).toBe('Example');
316
+ });
317
+
318
+ test('linkReference without title in definition does NOT have title attribute', () => {
319
+ const doc = parse('[link text][ref]\n\n[ref]: https://example.com\n');
320
+ const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
321
+ expect(a).toBeDefined();
322
+ const title = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'title');
323
+ expect(title).toBeUndefined();
324
+ // Only href attribute
325
+ expect(a!.attributes.length).toBe(1);
326
+ });
327
+ });
328
+
329
+ describe('GFM extensions', () => {
330
+ test('GFM table becomes table>tr>th/td elements', () => {
331
+ const doc = parse('| A | B |\n| - | - |\n| 1 | 2 |\n');
332
+ const table = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'table');
333
+ expect(table).toBeDefined();
334
+ const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
335
+ expect(ths.length).toBe(2);
336
+ const tds = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
337
+ expect(tds.length).toBe(2);
338
+ });
339
+
340
+ test('GFM table header cells contain correct text', () => {
341
+ const doc = parse('| Name | Age |\n| - | - |\n| Alice | 30 |\n');
342
+ const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
343
+ expect(ths.length).toBe(2);
344
+ // Verify text content of header cells
345
+ const thTexts = ths.map(th => th.childNodes.find(c => c.type === 'text')?.raw);
346
+ expect(thTexts).toStrictEqual(['Name', 'Age']);
347
+ });
348
+
349
+ test('GFM table data cells contain correct text', () => {
350
+ const doc = parse('| A | B |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |\n');
351
+ const tds = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
352
+ expect(tds.length).toBe(4);
353
+ const tdTexts = tds.map(td => td.childNodes.find(c => c.type === 'text')?.raw);
354
+ expect(tdTexts).toStrictEqual(['1', '2', '3', '4']);
355
+ });
356
+
357
+ test('GFM table with only header row has 0 td cells', () => {
358
+ const doc = parse('| A | B |\n| - | - |\n');
359
+ const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
360
+ expect(ths.length).toBe(2);
361
+ const tds = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
362
+ expect(tds.length).toBe(0);
363
+ });
364
+
365
+ test('GFM strikethrough becomes <del> element', () => {
366
+ const doc = parse('~~deleted~~\n');
367
+ const del = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'del');
368
+ expect(del).toBeDefined();
369
+ });
370
+
371
+ test('GFM strikethrough contains correct text content', () => {
372
+ const doc = parse('~~deleted text~~\n');
373
+ const del = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'del');
374
+ expect(del).toBeDefined();
375
+ const text = del!.childNodes.find(c => c.type === 'text');
376
+ expect(text).toBeDefined();
377
+ expect(text!.raw).toBe('deleted text');
378
+ });
379
+ });
380
+
381
+ describe('HTML blocks', () => {
382
+ test('simple div (remark html node)', () => {
383
+ const doc = parse('<div>hello</div>');
384
+ const maps = nodeListToDebugMaps(doc.nodeList);
385
+ expect(maps).toStrictEqual([
386
+ '[1:1]>[1:6](0,5)div: <div>',
387
+ '[1:6]>[1:11](5,10)#text: hello',
388
+ '[1:11]>[1:17](10,16)div: </div>',
389
+ ]);
390
+ });
391
+
392
+ test('HTML block in markdown', () => {
393
+ const doc = parse('# Heading\n\n<div class="note">\n <p>content</p>\n</div>\n');
394
+ // Heading is now an h1 element
395
+ const h1 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'h1');
396
+ expect(h1).toBeDefined();
397
+ // HTML block div is still present
398
+ const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
399
+ expect(div).toBeDefined();
400
+ });
401
+
402
+ test('HTML comment (block-level)', () => {
403
+ const doc = parse('<!-- comment -->\n');
404
+ const maps = nodeListToDebugMaps(doc.nodeList);
405
+ expect(maps).toStrictEqual(['[1:1]>[1:17](0,16)#comment: <!--\u2423comment\u2423-->']);
406
+ });
407
+
408
+ test('inline HTML in paragraph', () => {
409
+ const doc = parse('This is <em>emphasized</em> text');
410
+ const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
411
+ expect(p).toBeDefined();
412
+ const em = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'em');
413
+ expect(em).toBeDefined();
414
+ });
415
+
416
+ test('self-closing void element', () => {
417
+ const doc = parse('<hr>');
418
+ const maps = nodeListToDebugMaps(doc.nodeList);
419
+ expect(maps).toStrictEqual(['[1:1]>[1:5](0,4)hr: <hr>']);
420
+ });
421
+ });
422
+
423
+ describe('Front matter', () => {
424
+ test('YAML front matter becomes psblock', () => {
425
+ const doc = parse('---\ntitle: Test\n---\n\n<div>content</div>\n');
426
+ const yaml = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:yaml');
427
+ expect(yaml).toBeDefined();
428
+ const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
429
+ expect(div).toBeDefined();
430
+ });
431
+ });
432
+
433
+ describe('Adjacent HTML blocks', () => {
434
+ test('remark splits adjacent HTML blocks separated by blank lines', () => {
435
+ const doc = parse('<div class="wrapper">\n\n<p>paragraph inside div</p>\n\n</div>\n');
436
+ const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
437
+ expect(div).toBeDefined();
438
+ const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
439
+ expect(p).toBeDefined();
440
+ });
441
+ });
442
+
443
+ describe('Multiple HTML comments', () => {
444
+ test('consecutive comments are parsed', () => {
445
+ const doc = parse('<!-- TODO: fix this -->\n<!-- NOTE: this is a note -->\n');
446
+ const comments = doc.nodeList.filter(n => n?.type === 'comment');
447
+ expect(comments.length).toBe(2);
448
+ });
449
+ });
450
+
451
+ describe('HTML with entities', () => {
452
+ test('HTML entities in HTML blocks are preserved', () => {
453
+ const doc = parse('<p>&amp; &lt; &gt;</p>\n');
454
+ const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
455
+ expect(p).toBeDefined();
456
+ const text = doc.nodeList.find(n => n?.type === 'text' && n.raw.includes('&amp;'));
457
+ expect(text).toBeDefined();
458
+ });
459
+ });
460
+
461
+ describe('Multiple void elements', () => {
462
+ test('consecutive void elements with newlines between them', () => {
463
+ const doc = parse('<br>\n<hr>\n<img src="test.png" alt="test">\n');
464
+ const br = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'br');
465
+ const hr = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'hr');
466
+ const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
467
+ expect(br).toBeDefined();
468
+ expect(hr).toBeDefined();
469
+ expect(img).toBeDefined();
470
+ });
471
+ });
472
+
473
+ describe('State isolation between parse() calls', () => {
474
+ test('definitions do not leak across parse() calls', () => {
475
+ // First parse: define [ref]
476
+ const doc1 = parse('[link][ref]\n\n[ref]: https://example.com\n');
477
+ const a = doc1.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
478
+ expect(a).toBeDefined();
479
+
480
+ // Second parse: [ref] without definition — should NOT resolve
481
+ const doc2 = parse('[link][ref]\n');
482
+ const a2 = doc2.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
483
+ expect(a2).toBeUndefined();
484
+ const text = doc2.nodeList.find(n => n?.type === 'text');
485
+ expect(text).toBeDefined();
486
+ expect(text!.raw).toContain('[link][ref]');
487
+ });
488
+
489
+ test('table header state does not leak across parse() calls', () => {
490
+ // First parse: table with header
491
+ const doc1 = parse('| A |\n| - |\n| 1 |\n');
492
+ const ths1 = doc1.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
493
+ expect(ths1.length).toBe(1);
494
+
495
+ // Second parse: different table
496
+ const doc2 = parse('| B |\n| - |\n| 2 |\n');
497
+ const ths2 = doc2.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
498
+ expect(ths2.length).toBe(1);
499
+ const tds2 = doc2.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
500
+ expect(tds2.length).toBe(1);
501
+ });
502
+ });
503
+
504
+ describe('Multiple tables in one document', () => {
505
+ test('two GFM tables both have correct th/td', () => {
506
+ const doc = parse('| A | B |\n| - | - |\n| 1 | 2 |\n\n| C | D |\n| - | - |\n| 3 | 4 |\n');
507
+ const tables = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'table');
508
+ expect(tables.length).toBe(2);
509
+ const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
510
+ expect(ths.length).toBe(4);
511
+ const tds = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
512
+ expect(tds.length).toBe(4);
513
+ });
514
+ });
515
+
516
+ describe('collectDefinitions duplicate identifier', () => {
517
+ test('first definition wins for duplicate identifiers (CommonMark spec)', () => {
518
+ const doc = parse('[link][ref]\n\n[ref]: https://first.com\n[ref]: https://second.com\n');
519
+ const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
520
+ expect(a).toBeDefined();
521
+ const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
522
+ expect(href).toBeDefined();
523
+ expect(href!.value.raw).toBe('https://first.com');
524
+ });
525
+ });
526
+
527
+ describe('Empty link', () => {
528
+ test('empty link text [](url) produces <a> element', () => {
529
+ const doc = parse('[](https://example.com)\n');
530
+ const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
531
+ expect(a).toBeDefined();
532
+ const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
533
+ expect(href).toBeDefined();
534
+ expect(href!.value.raw).toBe('https://example.com');
535
+ });
536
+ });
537
+
538
+ describe('Footnotes', () => {
539
+ test('footnoteReference becomes psblock with correct raw', () => {
540
+ const doc = parse('Text with a note[^1]\n\n[^1]: Footnote content\n');
541
+ const fnRef = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:footnoteReference');
542
+ expect(fnRef).toBeDefined();
543
+ expect(fnRef!.nodeName).toBe('#ps:footnoteReference');
544
+ expect(fnRef!.raw).toBe('[^1]');
545
+ });
546
+
547
+ test('footnoteDefinition becomes psblock with correct raw', () => {
548
+ const doc = parse('Text with a note[^1]\n\n[^1]: Footnote content\n');
549
+ const fnDef = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:footnoteDefinition');
550
+ expect(fnDef).toBeDefined();
551
+ expect(fnDef!.nodeName).toBe('#ps:footnoteDefinition');
552
+ expect(fnDef!.raw).toContain('Footnote content');
553
+ });
554
+ });
555
+
556
+ describe('GFM table alignment', () => {
557
+ test('table with alignment syntax produces correct th/td', () => {
558
+ // TODO: GFM align attribute is not yet mapped to HTML align attribute
559
+ const doc = parse('| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |\n');
560
+ const table = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'table');
561
+ expect(table).toBeDefined();
562
+ const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
563
+ expect(ths.length).toBe(3);
564
+ const tds = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
565
+ expect(tds.length).toBe(3);
566
+ const thTexts = ths.map(th => th.childNodes.find(c => c.type === 'text')?.raw);
567
+ expect(thTexts).toStrictEqual(['Left', 'Center', 'Right']);
568
+ });
569
+ });
570
+
571
+ describe('Deep nesting', () => {
572
+ test('blockquote > list > emphasis > link', () => {
573
+ const doc = parse('> - *[link text](https://example.com)*\n');
574
+ const blockquote = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'blockquote');
575
+ expect(blockquote).toBeDefined();
576
+ const ul = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'ul');
577
+ expect(ul).toBeDefined();
578
+ const em = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'em');
579
+ expect(em).toBeDefined();
580
+ const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
581
+ expect(a).toBeDefined();
582
+ const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
583
+ expect(href!.value.raw).toBe('https://example.com');
584
+ });
585
+ });
586
+
587
+ describe('Edge cases', () => {
588
+ test('empty input produces empty nodeList', () => {
589
+ const doc = parse('');
590
+ expect(doc.nodeList).toStrictEqual([]);
591
+ });
592
+
593
+ test('whitespace-only input produces empty nodeList', () => {
594
+ const doc = parse(' \n');
595
+ expect(doc.nodeList).toStrictEqual([]);
596
+ });
597
+ });
598
+
599
+ describe('Error handling', () => {
600
+ test('invalid HTML in HTML block does not throw', () => {
601
+ // Markdown parser should gracefully handle invalid HTML
602
+ expect(() => parse('<div><span>unclosed tags')).not.toThrow();
603
+ });
604
+ });
605
+
606
+ describe('Attributes with special characters', () => {
607
+ test('attributes containing spaces are preserved in HTML blocks', () => {
608
+ const doc = parse('<div data-value="hello world" class="foo bar">content</div>\n');
609
+ const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
610
+ expect(div).toBeDefined();
611
+ const dataValue = div!.attributes.find(a => a.type === 'attr' && a.name.raw === 'data-value');
612
+ expect(dataValue).toBeDefined();
613
+ expect(dataValue!.value.raw).toBe('hello world');
614
+ });
615
+ });
616
+
617
+ describe('Nested HTML elements', () => {
618
+ test('deeply nested HTML elements are fully parsed', () => {
619
+ const doc = parse('<div>\n <span>\n <strong>deep</strong>\n </span>\n</div>\n');
620
+ const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
621
+ expect(div).toBeDefined();
622
+ const span = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'span');
623
+ expect(span).toBeDefined();
624
+ const strong = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'strong');
625
+ expect(strong).toBeDefined();
626
+ });
627
+ });
628
+
629
+ describe('Multiple separate HTML blocks in markdown', () => {
630
+ test('HTML blocks separated by markdown content', () => {
631
+ const doc = parse('# Heading 1\n\n<div>block 1</div>\n\nSome text.\n\n<div>block 2</div>\n');
632
+ const h1 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'h1');
633
+ expect(h1).toBeDefined();
634
+ const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
635
+ expect(p).toBeDefined();
636
+ const divs = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'div');
637
+ expect(divs.length).toBe(2);
638
+ });
639
+ });
640
+
641
+ describe('HTML immediately after front matter', () => {
642
+ test('HTML on the line immediately after front matter closing fence', () => {
643
+ const doc = parse('---\ntitle: Test\n---\n<div>immediately after</div>\n');
644
+ const yaml = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:yaml');
645
+ expect(yaml).toBeDefined();
646
+ const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
647
+ expect(div).toBeDefined();
648
+ });
649
+ });
650
+
651
+ describe('Mixed inline HTML tags', () => {
652
+ test('inline HTML tags within paragraph', () => {
653
+ const doc = parse('This has <em>emphasis</em> and <strong>strong</strong> text\n');
654
+ const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
655
+ expect(p).toBeDefined();
656
+ const em = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'em');
657
+ expect(em).toBeDefined();
658
+ const strong = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'strong');
659
+ expect(strong).toBeDefined();
660
+ });
661
+ });
662
+
663
+ describe('Document metadata', () => {
664
+ test('isFragment is true', () => {
665
+ const doc = parse('<div>hello</div>');
666
+ expect(doc.isFragment).toBe(true);
667
+ });
668
+
669
+ test('raw preserves original source', () => {
670
+ const source = '# Hello\n\n<div>world</div>\n';
671
+ const doc = parse(source);
672
+ expect(doc.raw).toBe(source);
673
+ });
674
+ });
675
+
676
+ describe('imageReference edge cases', () => {
677
+ test('imageReference without title in definition does NOT have title attribute', () => {
678
+ const doc = parse('![alt text][img]\n\n[img]: image.png\n');
679
+ const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
680
+ expect(img).toBeDefined();
681
+ const title = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'title');
682
+ expect(title).toBeUndefined();
683
+ // Only src and alt attributes
684
+ expect(img!.attributes.length).toBe(2);
685
+ });
686
+
687
+ test('imageReference with empty alt ![][ref]', () => {
688
+ const doc = parse('![][img]\n\n[img]: image.png\n');
689
+ const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
690
+ expect(img).toBeDefined();
691
+ const alt = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'alt');
692
+ expect(alt).toBeDefined();
693
+ expect(alt!.value.raw).toBe('');
694
+ const src = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'src');
695
+ expect(src).toBeDefined();
696
+ expect(src!.value.raw).toBe('image.png');
697
+ });
698
+
699
+ test('same definition used by multiple imageReferences', () => {
700
+ const doc = parse('![first][img]\n\n![second][img]\n\n[img]: photo.png\n');
701
+ const imgs = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'img');
702
+ expect(imgs.length).toBe(2);
703
+ const alts = imgs.map(
704
+ img => img.attributes.find(a => a.type === 'attr' && a.name.raw === 'alt')?.value.raw,
705
+ );
706
+ expect(alts).toStrictEqual(['first', 'second']);
707
+ const srcValues = imgs.map(
708
+ img => img.attributes.find(a => a.type === 'attr' && a.name.raw === 'src')?.value.raw,
709
+ );
710
+ expect(srcValues).toStrictEqual(['photo.png', 'photo.png']);
711
+ });
712
+ });
713
+
714
+ describe('State isolation: empty-nonEmpty-empty cycle', () => {
715
+ test('empty parse after non-empty parse returns empty nodeList', () => {
716
+ const doc1 = parse('# Heading\n\n[link][ref]\n\n[ref]: url\n');
717
+ expect(doc1.nodeList.length).toBeGreaterThan(0);
718
+
719
+ const doc2 = parse('');
720
+ expect(doc2.nodeList).toStrictEqual([]);
721
+
722
+ const doc3 = parse('# Another heading');
723
+ const h1 = doc3.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'h1');
724
+ expect(h1).toBeDefined();
725
+ });
726
+ });
727
+ });
728
+
729
+ describe('getLineAndColumn', () => {
730
+ test('returns line 1, col 1 for offset 0', () => {
731
+ expect(getLineAndColumn('hello', 0)).toStrictEqual({ line: 1, col: 1 });
732
+ });
733
+
734
+ test('correctly counts lines across newlines', () => {
735
+ expect(getLineAndColumn('aaa\nbbb\nccc', 4)).toStrictEqual({ line: 2, col: 1 });
736
+ expect(getLineAndColumn('aaa\nbbb\nccc', 5)).toStrictEqual({ line: 2, col: 2 });
737
+ expect(getLineAndColumn('aaa\nbbb\nccc', 8)).toStrictEqual({ line: 3, col: 1 });
738
+ });
739
+
740
+ test('handles empty string at offset 0', () => {
741
+ expect(getLineAndColumn('', 0)).toStrictEqual({ line: 1, col: 1 });
742
+ });
743
+
744
+ test('offset at end of line (before newline)', () => {
745
+ expect(getLineAndColumn('abc\n', 3)).toStrictEqual({ line: 1, col: 4 });
746
+ });
747
+ });