@markuplint/mdx-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.
- package/ARCHITECTURE.md +212 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.ja.md +43 -0
- package/README.md +43 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- package/lib/parser.d.ts +64 -0
- package/lib/parser.js +260 -0
- package/package.json +40 -0
- package/src/index.spec.ts +923 -0
- package/src/index.ts +4 -0
- package/src/parser.ts +308 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { nodeListToDebugMaps } from '@markuplint/parser-utils';
|
|
4
|
+
|
|
5
|
+
import { parser } from './parser.js';
|
|
6
|
+
|
|
7
|
+
function parse(code: string) {
|
|
8
|
+
return parser.parse(code);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('MDXParser', () => {
|
|
12
|
+
describe('JSX elements', () => {
|
|
13
|
+
test('self-closing component', () => {
|
|
14
|
+
const doc = parse('<MyComponent prop="value" />');
|
|
15
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
16
|
+
expect(maps).toStrictEqual(['[1:1]>[1:29](0,28)MyComponent: <MyComponent␣prop="value"␣/>']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('HTML element with children', () => {
|
|
20
|
+
const doc = parse('<div>hello</div>');
|
|
21
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
22
|
+
expect(maps).toStrictEqual([
|
|
23
|
+
'[1:1]>[1:6](0,5)div: <div>',
|
|
24
|
+
'[1:6]>[1:11](5,10)#text: hello',
|
|
25
|
+
'[1:11]>[1:17](10,16)div: </div>',
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('block-level JSX element', () => {
|
|
30
|
+
const doc = parse('<Card>\n content\n</Card>\n');
|
|
31
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
32
|
+
expect(maps[0]).toBe('[1:1]>[1:7](0,6)Card: <Card>');
|
|
33
|
+
expect(maps.at(-1)).toBe('[3:1]>[3:8](17,24)Card: </Card>');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('Component detection', () => {
|
|
38
|
+
test('uppercase name is authored element', () => {
|
|
39
|
+
const doc = parse('<MyComponent />');
|
|
40
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
41
|
+
expect(startTag?.elementType).toBe('authored');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('lowercase name is HTML element', () => {
|
|
45
|
+
const doc = parse('<div>x</div>');
|
|
46
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
47
|
+
expect(startTag?.elementType).toBe('html');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('Expressions', () => {
|
|
52
|
+
test('block expression becomes psblock', () => {
|
|
53
|
+
const doc = parse('{1 + 1}');
|
|
54
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
55
|
+
expect(maps).toStrictEqual(['[1:1]>[1:8](0,7)#ps:mdxFlowExpression: {1␣+␣1}']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('import becomes psblock', () => {
|
|
59
|
+
const doc = parse('import X from "./x"');
|
|
60
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
61
|
+
expect(maps).toStrictEqual(['[1:1]>[1:20](0,19)#ps:mdxjsEsm: import␣X␣from␣"./x"']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('export becomes psblock', () => {
|
|
65
|
+
const doc = parse('export const x = 1');
|
|
66
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
67
|
+
expect(maps).toStrictEqual(['[1:1]>[1:19](0,18)#ps:mdxjsEsm: export␣const␣x␣=␣1']);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('Markdown content', () => {
|
|
72
|
+
test('heading becomes h1 element', () => {
|
|
73
|
+
const doc = parse('# Hello');
|
|
74
|
+
const h1 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'h1');
|
|
75
|
+
expect(h1).toBeDefined();
|
|
76
|
+
expect(h1!.elementType).toBe('html');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test.each([
|
|
80
|
+
['## H2', 'h2'],
|
|
81
|
+
['### H3', 'h3'],
|
|
82
|
+
['#### H4', 'h4'],
|
|
83
|
+
['##### H5', 'h5'],
|
|
84
|
+
['###### H6', 'h6'],
|
|
85
|
+
] as const)('heading %s becomes %s element', (md, tag) => {
|
|
86
|
+
const doc = parse(md);
|
|
87
|
+
const el = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === tag);
|
|
88
|
+
expect(el).toBeDefined();
|
|
89
|
+
expect(el!.elementType).toBe('html');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('paragraph becomes p element', () => {
|
|
93
|
+
const doc = parse('Some text here.');
|
|
94
|
+
const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
|
|
95
|
+
expect(p).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('emphasis becomes em element', () => {
|
|
99
|
+
const doc = parse('*emphasized*');
|
|
100
|
+
const em = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'em');
|
|
101
|
+
expect(em).toBeDefined();
|
|
102
|
+
const text = em!.childNodes.find(c => c.type === 'text');
|
|
103
|
+
expect(text).toBeDefined();
|
|
104
|
+
expect(text!.raw).toBe('emphasized');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('strong becomes strong element', () => {
|
|
108
|
+
const doc = parse('**bold**');
|
|
109
|
+
const strong = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'strong');
|
|
110
|
+
expect(strong).toBeDefined();
|
|
111
|
+
const text = strong!.childNodes.find(c => c.type === 'text');
|
|
112
|
+
expect(text).toBeDefined();
|
|
113
|
+
expect(text!.raw).toBe('bold');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('link becomes <a> with href', () => {
|
|
117
|
+
const doc = parse('[link](https://example.com)');
|
|
118
|
+
const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
|
|
119
|
+
expect(a).toBeDefined();
|
|
120
|
+
const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
|
|
121
|
+
expect(href).toBeDefined();
|
|
122
|
+
expect(href!.value.raw).toBe('https://example.com');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('image becomes <img> with src and alt', () => {
|
|
126
|
+
const doc = parse('');
|
|
127
|
+
const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
|
|
128
|
+
expect(img).toBeDefined();
|
|
129
|
+
const src = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'src');
|
|
130
|
+
expect(src!.value.raw).toBe('image.png');
|
|
131
|
+
const alt = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'alt');
|
|
132
|
+
expect(alt!.value.raw).toBe('alt text');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('Front matter', () => {
|
|
137
|
+
test('YAML front matter becomes psblock', () => {
|
|
138
|
+
const doc = parse('---\ntitle: Test\n---\n\n<div>x</div>\n');
|
|
139
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
140
|
+
expect(maps[0]).toBe('[1:1]>[3:4](0,19)#ps:yaml: ---⏎title:␣Test⏎---');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('Error handling', () => {
|
|
145
|
+
test('unclosed JSX tag throws ParserError', () => {
|
|
146
|
+
// MDX requires explicit closing tags, unlike HTML.
|
|
147
|
+
// remark-mdx throws a ParserError when a tag is not closed.
|
|
148
|
+
expect(() => parse('<div>unclosed tag in MDX\n')).toThrow(/Expected a closing tag for `<div>`/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('invalid expression throws ParserError', () => {
|
|
152
|
+
// Malformed JS expressions inside {} are rejected by acorn.
|
|
153
|
+
expect(() => parse('{1 +}\n')).toThrow(/Could not parse expression with acorn/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('mismatched closing tag throws ParserError', () => {
|
|
157
|
+
// MDX enforces matching open/close tag names (XML-style).
|
|
158
|
+
expect(() => parse('<div>text</span>\n')).toThrow(
|
|
159
|
+
/Unexpected closing tag `<\/span>`, expected corresponding closing tag for `<div>`/,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('empty input returns empty nodeList without throwing', () => {
|
|
164
|
+
const doc = parse('');
|
|
165
|
+
expect(doc.nodeList).toStrictEqual([]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('Document metadata', () => {
|
|
170
|
+
test('isFragment is true', () => {
|
|
171
|
+
const doc = parse('<div>hello</div>');
|
|
172
|
+
expect(doc.isFragment).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('raw preserves original source', () => {
|
|
176
|
+
const source = '# Hello\n\n<div>world</div>\n';
|
|
177
|
+
const doc = parse(source);
|
|
178
|
+
expect(doc.raw).toBe(source);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('JSX Fragments', () => {
|
|
183
|
+
test('fragment with children produces starttag node', () => {
|
|
184
|
+
const doc = parse('<>\n <div>inside fragment</div>\n</>');
|
|
185
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
186
|
+
expect(maps).toStrictEqual(['[1:1]>[1:3](0,2)#jsx-fragment: <>']);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('fragment starttag has nodeName #jsx-fragment', () => {
|
|
190
|
+
const doc = parse('<>\n <div>inside fragment</div>\n</>');
|
|
191
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
192
|
+
expect(startTag?.nodeName).toBe('#jsx-fragment');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('Dot notation components', () => {
|
|
197
|
+
test('dot notation component is parsed as authored element', () => {
|
|
198
|
+
const doc = parse('<Layout.Header>content</Layout.Header>');
|
|
199
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
200
|
+
expect(maps).toStrictEqual([
|
|
201
|
+
'[1:1]>[1:16](0,15)Layout.Header: <Layout.Header>',
|
|
202
|
+
'[1:16]>[1:23](15,22)#text: content',
|
|
203
|
+
'[1:23]>[1:39](22,38)Layout.Header: </Layout.Header>',
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('dot notation elementType is authored', () => {
|
|
208
|
+
const doc = parse('<Layout.Header>content</Layout.Header>');
|
|
209
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
210
|
+
expect(startTag?.elementType).toBe('authored');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('Expression attributes', () => {
|
|
215
|
+
test('curly-brace attribute values are marked as dynamic', () => {
|
|
216
|
+
const doc = parse('<Component data={value} style={{ color: "red" }} />');
|
|
217
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
218
|
+
expect(startTag).toBeDefined();
|
|
219
|
+
const attrs = startTag!.attributes;
|
|
220
|
+
expect(attrs).toHaveLength(2);
|
|
221
|
+
|
|
222
|
+
const dataAttr = attrs.find(a => a.type === 'attr' && a.name.raw === 'data');
|
|
223
|
+
expect(dataAttr).toBeDefined();
|
|
224
|
+
expect(dataAttr!.isDynamicValue).toBe(true);
|
|
225
|
+
expect(dataAttr!.value.raw).toBe('value');
|
|
226
|
+
|
|
227
|
+
const styleAttr = attrs.find(a => a.type === 'attr' && a.name.raw === 'style');
|
|
228
|
+
expect(styleAttr).toBeDefined();
|
|
229
|
+
expect(styleAttr!.isDynamicValue).toBe(true);
|
|
230
|
+
expect(styleAttr!.value.raw).toBe('{ color: "red" }');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('debug map for expression attributes', () => {
|
|
234
|
+
const doc = parse('<Component data={value} style={{ color: "red" }} />');
|
|
235
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
236
|
+
expect(maps).toStrictEqual([
|
|
237
|
+
'[1:1]>[1:52](0,51)Component: <Component␣data={value}␣style={{␣color:␣"red"␣}}␣/>',
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('Spread attributes', () => {
|
|
243
|
+
test('spread attribute is detected as spread type', () => {
|
|
244
|
+
const doc = parse('<Component {...props} />');
|
|
245
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
246
|
+
expect(startTag).toBeDefined();
|
|
247
|
+
const attrs = startTag!.attributes;
|
|
248
|
+
expect(attrs).toHaveLength(1);
|
|
249
|
+
expect(attrs[0].type).toBe('spread');
|
|
250
|
+
expect(attrs[0].raw).toBe('{...props}');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('debug map for spread attribute element', () => {
|
|
254
|
+
const doc = parse('<Component {...props} />');
|
|
255
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
256
|
+
expect(maps).toStrictEqual(['[1:1]>[1:25](0,24)Component: <Component␣{...props}␣/>']);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('Nested JSX elements', () => {
|
|
261
|
+
test('outer element wraps inner as child', () => {
|
|
262
|
+
const doc = parse('<Outer>\n <Inner>content</Inner>\n</Outer>');
|
|
263
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
264
|
+
expect(maps[0]).toBe('[1:1]>[1:8](0,7)Outer: <Outer>');
|
|
265
|
+
expect(maps.at(-1)).toBe('[3:1]>[3:9](33,41)Outer: </Outer>');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('inner JSX element appears in nodeList', () => {
|
|
269
|
+
const doc = parse('<Outer>\n <Inner>content</Inner>\n</Outer>');
|
|
270
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
271
|
+
// remark-mdx wraps inline children in a paragraph; now parsed as p element
|
|
272
|
+
expect(maps[1]).toBe('[2:3]>[2:10](10,17)Inner: <Inner>');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('outer element types are authored', () => {
|
|
276
|
+
const doc = parse('<Outer>\n <Inner>content</Inner>\n</Outer>');
|
|
277
|
+
const outerStart = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Outer');
|
|
278
|
+
expect(outerStart?.elementType).toBe('authored');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('Mixed content in paragraphs (inline JSX)', () => {
|
|
283
|
+
test('paragraph with inline JSX is unwrapped into individual nodes', () => {
|
|
284
|
+
const doc = parse('Text with <Badge color="blue">inline</Badge> component.');
|
|
285
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
286
|
+
expect(maps).toStrictEqual([
|
|
287
|
+
'[1:1]>[1:11](0,10)#text: Text␣with␣',
|
|
288
|
+
'[1:11]>[1:31](10,30)Badge: <Badge␣color="blue">',
|
|
289
|
+
'[1:31]>[1:37](30,36)#text: inline',
|
|
290
|
+
'[1:37]>[1:45](36,44)Badge: </Badge>',
|
|
291
|
+
'[1:45]>[1:56](44,55)#text: ␣component.',
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('inline JSX component is authored element', () => {
|
|
296
|
+
const doc = parse('Text with <Badge color="blue">inline</Badge> component.');
|
|
297
|
+
const badge = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Badge');
|
|
298
|
+
expect(badge?.elementType).toBe('authored');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('JSX comments', () => {
|
|
303
|
+
test('JSX comment becomes mdxFlowExpression psblock', () => {
|
|
304
|
+
const doc = parse('{/* This is a comment */}');
|
|
305
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
306
|
+
expect(maps).toStrictEqual(['[1:1]>[1:26](0,25)#ps:mdxFlowExpression: {/*␣This␣is␣a␣comment␣*/}']);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('Multiple imports with component usage', () => {
|
|
311
|
+
test('imports are combined into single ESM psblock', () => {
|
|
312
|
+
const doc = parse(
|
|
313
|
+
'import { Alert } from "./Alert"\nimport { Badge } from "./Badge"\n\n<Alert type="warning">\n <Badge>NEW</Badge>\n</Alert>',
|
|
314
|
+
);
|
|
315
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
316
|
+
|
|
317
|
+
// Both imports become a single mdxjsEsm psblock
|
|
318
|
+
expect(maps[0]).toBe(
|
|
319
|
+
'[1:1]>[2:32](0,63)#ps:mdxjsEsm: import␣{␣Alert␣}␣from␣"./Alert"⏎import␣{␣Badge␣}␣from␣"./Badge"',
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Alert element start tag
|
|
323
|
+
expect(maps[1]).toBe('[4:1]>[4:23](65,87)Alert: <Alert␣type="warning">');
|
|
324
|
+
|
|
325
|
+
// Alert element end tag
|
|
326
|
+
expect(maps.at(-1)).toBe('[6:1]>[6:9](109,117)Alert: </Alert>');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('Alert component is authored', () => {
|
|
330
|
+
const doc = parse(
|
|
331
|
+
'import { Alert } from "./Alert"\nimport { Badge } from "./Badge"\n\n<Alert type="warning">\n <Badge>NEW</Badge>\n</Alert>',
|
|
332
|
+
);
|
|
333
|
+
const alert = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Alert');
|
|
334
|
+
expect(alert?.elementType).toBe('authored');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('Markdown mixed with JSX', () => {
|
|
339
|
+
test('realistic MDX document structure', () => {
|
|
340
|
+
const doc = parse('# Heading\n\nSome paragraph text.\n\n<Card>content</Card>\n\nMore paragraph text.');
|
|
341
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
342
|
+
expect(maps).toStrictEqual([
|
|
343
|
+
'[1:1]>[1:10](0,9)h1: #␣Heading',
|
|
344
|
+
'[1:3]>[1:10](2,9)#text: Heading',
|
|
345
|
+
'[3:1]>[3:21](11,31)p: Some␣paragraph␣text.',
|
|
346
|
+
'[3:1]>[3:21](11,31)#text: Some␣paragraph␣text.',
|
|
347
|
+
'[5:1]>[5:7](33,39)Card: <Card>',
|
|
348
|
+
'[5:7]>[5:14](39,46)#text: content',
|
|
349
|
+
'[5:14]>[5:21](46,53)Card: </Card>',
|
|
350
|
+
'[7:1]>[7:21](55,75)p: More␣paragraph␣text.',
|
|
351
|
+
'[7:1]>[7:21](55,75)#text: More␣paragraph␣text.',
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('heading is an h1 element', () => {
|
|
356
|
+
const doc = parse('# Heading\n\nSome paragraph text.\n\n<Card>content</Card>\n\nMore paragraph text.');
|
|
357
|
+
const heading = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'h1');
|
|
358
|
+
expect(heading).toBeDefined();
|
|
359
|
+
expect(heading!.type).toBe('starttag');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('Card element is authored', () => {
|
|
363
|
+
const doc = parse('# Heading\n\nSome paragraph text.\n\n<Card>content</Card>\n\nMore paragraph text.');
|
|
364
|
+
const card = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Card');
|
|
365
|
+
expect(card?.elementType).toBe('authored');
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('Boolean attributes in JSX', () => {
|
|
370
|
+
test('boolean attributes have empty value', () => {
|
|
371
|
+
const doc = parse('<Input disabled required name="email" />');
|
|
372
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
373
|
+
expect(startTag).toBeDefined();
|
|
374
|
+
const attrs = startTag!.attributes;
|
|
375
|
+
expect(attrs).toHaveLength(3);
|
|
376
|
+
|
|
377
|
+
const disabled = attrs.find(a => a.type === 'attr' && a.name.raw === 'disabled');
|
|
378
|
+
expect(disabled).toBeDefined();
|
|
379
|
+
expect(disabled!.value.raw).toBe('');
|
|
380
|
+
|
|
381
|
+
const required = attrs.find(a => a.type === 'attr' && a.name.raw === 'required');
|
|
382
|
+
expect(required).toBeDefined();
|
|
383
|
+
expect(required!.value.raw).toBe('');
|
|
384
|
+
|
|
385
|
+
const name = attrs.find(a => a.type === 'attr' && a.name.raw === 'name');
|
|
386
|
+
expect(name).toBeDefined();
|
|
387
|
+
expect(name!.value.raw).toBe('email');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('debug map for boolean attributes', () => {
|
|
391
|
+
const doc = parse('<Input disabled required name="email" />');
|
|
392
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
393
|
+
expect(maps).toStrictEqual(['[1:1]>[1:41](0,40)Input: <Input␣disabled␣required␣name="email"␣/>']);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('Self-closing HTML void elements in MDX', () => {
|
|
398
|
+
test('void elements are parsed as individual nodes', () => {
|
|
399
|
+
const doc = parse('<br />\n<hr />\n<img src="test.png" alt="test" />');
|
|
400
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
401
|
+
expect(maps).toStrictEqual([
|
|
402
|
+
'[1:1]>[1:7](0,6)br: <br␣/>',
|
|
403
|
+
'[2:1]>[2:7](7,13)hr: <hr␣/>',
|
|
404
|
+
'[3:1]>[3:34](14,47)img: <img␣src="test.png"␣alt="test"␣/>',
|
|
405
|
+
]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('void elements are html type', () => {
|
|
409
|
+
const doc = parse('<br />\n<hr />\n<img src="test.png" alt="test" />');
|
|
410
|
+
const br = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'br');
|
|
411
|
+
const hr = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'hr');
|
|
412
|
+
const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
|
|
413
|
+
expect(br?.elementType).toBe('html');
|
|
414
|
+
expect(hr?.elementType).toBe('html');
|
|
415
|
+
expect(img?.elementType).toBe('html');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('img element has attributes', () => {
|
|
419
|
+
const doc = parse('<br />\n<hr />\n<img src="test.png" alt="test" />');
|
|
420
|
+
const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
|
|
421
|
+
expect(img).toBeDefined();
|
|
422
|
+
expect(img!.attributes).toHaveLength(2);
|
|
423
|
+
|
|
424
|
+
const src = img!.attributes.find(a => a.type === 'attr' && a.name.raw === 'src');
|
|
425
|
+
expect(src).toBeDefined();
|
|
426
|
+
expect(src!.value.raw).toBe('test.png');
|
|
427
|
+
|
|
428
|
+
const alt = img!.attributes.find(a => a.type === 'attr' && a.name.raw === 'alt');
|
|
429
|
+
expect(alt).toBeDefined();
|
|
430
|
+
expect(alt!.value.raw).toBe('test');
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe('Empty component (no children, no attributes)', () => {
|
|
435
|
+
test('empty self-closing component is parsed correctly', () => {
|
|
436
|
+
const doc = parse('<Spacer />');
|
|
437
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
438
|
+
expect(maps).toStrictEqual(['[1:1]>[1:11](0,10)Spacer: <Spacer␣/>']);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('empty component is authored element', () => {
|
|
442
|
+
const doc = parse('<Spacer />');
|
|
443
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
444
|
+
expect(startTag?.elementType).toBe('authored');
|
|
445
|
+
expect(startTag?.nodeName).toBe('Spacer');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('empty component has no attributes', () => {
|
|
449
|
+
const doc = parse('<Spacer />');
|
|
450
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag');
|
|
451
|
+
expect(startTag).toBeDefined();
|
|
452
|
+
expect(startTag!.attributes).toHaveLength(0);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe('Link and image references', () => {
|
|
457
|
+
test('linkReference resolves to <a> element', () => {
|
|
458
|
+
const doc = parse('[link text][ref]\n\n[ref]: https://example.com "Example"\n');
|
|
459
|
+
const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
|
|
460
|
+
expect(a).toBeDefined();
|
|
461
|
+
const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
|
|
462
|
+
expect(href).toBeDefined();
|
|
463
|
+
expect(href!.value.raw).toBe('https://example.com');
|
|
464
|
+
const title = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'title');
|
|
465
|
+
expect(title).toBeDefined();
|
|
466
|
+
expect(title!.value.raw).toBe('Example');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('linkReference without title does NOT have title attribute', () => {
|
|
470
|
+
const doc = parse('[link text][ref]\n\n[ref]: https://example.com\n');
|
|
471
|
+
const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
|
|
472
|
+
expect(a).toBeDefined();
|
|
473
|
+
const title = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'title');
|
|
474
|
+
expect(title).toBeUndefined();
|
|
475
|
+
expect(a!.attributes.length).toBe(1);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('imageReference resolves to <img> element with alt', () => {
|
|
479
|
+
const doc = parse('![alt text][img]\n\n[img]: image.png\n');
|
|
480
|
+
const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
|
|
481
|
+
expect(img).toBeDefined();
|
|
482
|
+
const src = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'src');
|
|
483
|
+
expect(src).toBeDefined();
|
|
484
|
+
expect(src!.value.raw).toBe('image.png');
|
|
485
|
+
const alt = img!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'alt');
|
|
486
|
+
expect(alt).toBeDefined();
|
|
487
|
+
expect(alt!.value.raw).toBe('alt text');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('unresolved linkReference is treated as plain text', () => {
|
|
491
|
+
const doc = parse('[text][missing]\n');
|
|
492
|
+
const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
|
|
493
|
+
expect(a).toBeUndefined();
|
|
494
|
+
const text = doc.nodeList.find(n => n?.type === 'text');
|
|
495
|
+
expect(text).toBeDefined();
|
|
496
|
+
expect(text!.raw).toContain('[text][missing]');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('unresolved imageReference is treated as plain text', () => {
|
|
500
|
+
const doc = parse('![alt][missing]\n');
|
|
501
|
+
const img = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'img');
|
|
502
|
+
expect(img).toBeUndefined();
|
|
503
|
+
const text = doc.nodeList.find(n => n?.type === 'text');
|
|
504
|
+
expect(text).toBeDefined();
|
|
505
|
+
expect(text!.raw).toContain('![alt][missing]');
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe('Blockquote with JSX', () => {
|
|
510
|
+
test('JSX inside blockquote is parsed correctly', () => {
|
|
511
|
+
const doc = parse('> <Badge>test</Badge>\n');
|
|
512
|
+
const blockquote = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'blockquote');
|
|
513
|
+
expect(blockquote).toBeDefined();
|
|
514
|
+
const badge = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Badge');
|
|
515
|
+
expect(badge).toBeDefined();
|
|
516
|
+
expect(badge!.elementType).toBe('authored');
|
|
517
|
+
const text = badge!.childNodes.find(c => c.type === 'text');
|
|
518
|
+
expect(text).toBeDefined();
|
|
519
|
+
expect(text!.raw).toBe('test');
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe('HTML nodes in MDX', () => {
|
|
524
|
+
test('HTML comment syntax is invalid in MDX v2 (use JSX comment instead)', () => {
|
|
525
|
+
// MDX v2 does not support HTML comments; it requires {/* */} syntax
|
|
526
|
+
expect(() => parse('<!-- comment -->\n')).toThrow(/Unexpected character `!`/);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('JSX comment is valid alternative to HTML comment', () => {
|
|
530
|
+
const doc = parse('{/* comment */}\n');
|
|
531
|
+
const psblock = doc.nodeList.find(n => n?.type === 'psblock');
|
|
532
|
+
expect(psblock).toBeDefined();
|
|
533
|
+
expect(psblock!.raw).toContain('/* comment */');
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe('Footnotes', () => {
|
|
538
|
+
test('footnoteReference becomes psblock with correct raw', () => {
|
|
539
|
+
const doc = parse('Text with a note[^1]\n\n[^1]: Footnote content\n');
|
|
540
|
+
const fnRef = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:footnoteReference');
|
|
541
|
+
expect(fnRef).toBeDefined();
|
|
542
|
+
expect(fnRef!.nodeName).toBe('#ps:footnoteReference');
|
|
543
|
+
expect(fnRef!.raw).toBe('[^1]');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('footnoteDefinition becomes psblock with correct raw', () => {
|
|
547
|
+
const doc = parse('Text with a note[^1]\n\n[^1]: Footnote content\n');
|
|
548
|
+
const fnDef = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:footnoteDefinition');
|
|
549
|
+
expect(fnDef).toBeDefined();
|
|
550
|
+
expect(fnDef!.nodeName).toBe('#ps:footnoteDefinition');
|
|
551
|
+
expect(fnDef!.raw).toContain('Footnote content');
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
describe('State isolation between parse() calls', () => {
|
|
556
|
+
test('definitions do not leak across MDX parse() calls', () => {
|
|
557
|
+
// First parse: define [ref]
|
|
558
|
+
const doc1 = parse('[link][ref]\n\n[ref]: https://example.com\n');
|
|
559
|
+
const a1 = doc1.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
|
|
560
|
+
expect(a1).toBeDefined();
|
|
561
|
+
|
|
562
|
+
// Second parse: [ref] without definition — should NOT resolve
|
|
563
|
+
const doc2 = parse('[link][ref]\n');
|
|
564
|
+
const a2 = doc2.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
|
|
565
|
+
expect(a2).toBeUndefined();
|
|
566
|
+
const text = doc2.nodeList.find(n => n?.type === 'text');
|
|
567
|
+
expect(text).toBeDefined();
|
|
568
|
+
expect(text!.raw).toContain('[link][ref]');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('table header state does not leak across MDX parse() calls', () => {
|
|
572
|
+
const doc1 = parse('| A |\n| - |\n| 1 |\n');
|
|
573
|
+
const ths1 = doc1.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
|
|
574
|
+
expect(ths1.length).toBe(1);
|
|
575
|
+
|
|
576
|
+
const doc2 = parse('| B |\n| - |\n| 2 |\n');
|
|
577
|
+
const ths2 = doc2.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
|
|
578
|
+
expect(ths2.length).toBe(1);
|
|
579
|
+
const tds2 = doc2.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
|
|
580
|
+
expect(tds2.length).toBe(1);
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe('Inline expression in paragraph (flattenMdastChildren)', () => {
|
|
585
|
+
test('paragraph with inline expression is unwrapped', () => {
|
|
586
|
+
const doc = parse('Text {variable} more text');
|
|
587
|
+
const expr = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:mdxTextExpression');
|
|
588
|
+
expect(expr).toBeDefined();
|
|
589
|
+
expect(expr!.raw).toBe('{variable}');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('paragraph without JSX or expressions is preserved as <p>', () => {
|
|
593
|
+
const doc = parse('Just plain paragraph text.');
|
|
594
|
+
const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
|
|
595
|
+
expect(p).toBeDefined();
|
|
596
|
+
// No unwrapping happened — text is inside <p>
|
|
597
|
+
const text = p!.childNodes.find(c => c.type === 'text');
|
|
598
|
+
expect(text).toBeDefined();
|
|
599
|
+
expect(text!.raw).toBe('Just plain paragraph text.');
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe('MDX inline code and code blocks', () => {
|
|
604
|
+
test('inline code in MDX produces <code> element', () => {
|
|
605
|
+
const doc = parse('Use `const x = 1` here');
|
|
606
|
+
const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
|
|
607
|
+
expect(code).toBeDefined();
|
|
608
|
+
expect(code!.childNodes.length).toBe(1);
|
|
609
|
+
expect(code!.childNodes[0].raw).toBe('const x = 1');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('fenced code block in MDX produces pre>code elements', () => {
|
|
613
|
+
const doc = parse('```typescript\nconst x: number = 1;\n```\n');
|
|
614
|
+
const pre = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'pre');
|
|
615
|
+
expect(pre).toBeDefined();
|
|
616
|
+
const code = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'code');
|
|
617
|
+
expect(code).toBeDefined();
|
|
618
|
+
const langAttr = code!.attributes.find(a => a.type === 'attr' && a.name.raw === 'class');
|
|
619
|
+
expect(langAttr).toBeDefined();
|
|
620
|
+
expect(langAttr!.value.raw).toBe('language-typescript');
|
|
621
|
+
expect(code!.childNodes.length).toBe(1);
|
|
622
|
+
expect(code!.childNodes[0].raw).toBe('const x: number = 1;');
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
describe('Export default', () => {
|
|
627
|
+
test('export default function becomes mdxjsEsm psblock', () => {
|
|
628
|
+
const doc = parse('export default function Layout() {}\n');
|
|
629
|
+
const esm = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:mdxjsEsm');
|
|
630
|
+
expect(esm).toBeDefined();
|
|
631
|
+
expect(esm!.raw).toContain('export default function Layout');
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test('export default object expression', () => {
|
|
635
|
+
const doc = parse("export default { title: 'My Page' }\n");
|
|
636
|
+
const esm = doc.nodeList.find(n => n?.type === 'psblock' && n.nodeName === '#ps:mdxjsEsm');
|
|
637
|
+
expect(esm).toBeDefined();
|
|
638
|
+
expect(esm!.raw).toContain('export default');
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
describe('Multiple named exports', () => {
|
|
643
|
+
test('multiple export const statements become mdxjsEsm psblock', () => {
|
|
644
|
+
const doc = parse('export const a = 1\nexport const b = 2\n');
|
|
645
|
+
const esmBlocks = doc.nodeList.filter(n => n?.type === 'psblock' && n.nodeName === '#ps:mdxjsEsm');
|
|
646
|
+
// remark-mdx combines adjacent exports into one ESM block
|
|
647
|
+
expect(esmBlocks.length).toBeGreaterThanOrEqual(1);
|
|
648
|
+
const raw = esmBlocks.map(e => e.raw).join('');
|
|
649
|
+
expect(raw).toContain('export const a');
|
|
650
|
+
expect(raw).toContain('export const b');
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe('Multi-line JSX attributes', () => {
|
|
655
|
+
test('attributes spanning multiple lines are parsed correctly', () => {
|
|
656
|
+
const doc = parse('<Component\n name="test"\n value={42}\n disabled\n/>\n');
|
|
657
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Component');
|
|
658
|
+
expect(startTag).toBeDefined();
|
|
659
|
+
expect(startTag!.attributes.length).toBe(3);
|
|
660
|
+
const name = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'name');
|
|
661
|
+
expect(name).toBeDefined();
|
|
662
|
+
expect(name!.value.raw).toBe('test');
|
|
663
|
+
const value = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'value');
|
|
664
|
+
expect(value).toBeDefined();
|
|
665
|
+
expect(value!.isDynamicValue).toBe(true);
|
|
666
|
+
expect(value!.value.raw).toBe('42');
|
|
667
|
+
const disabled = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'disabled');
|
|
668
|
+
expect(disabled).toBeDefined();
|
|
669
|
+
expect(disabled!.value.raw).toBe('');
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe('JSX children templates', () => {
|
|
674
|
+
test('nested component structure (Card > CardHeader + CardBody)', () => {
|
|
675
|
+
const doc = parse('<Card>\n <CardHeader>Title</CardHeader>\n <CardBody>Content</CardBody>\n</Card>\n');
|
|
676
|
+
const card = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Card');
|
|
677
|
+
expect(card).toBeDefined();
|
|
678
|
+
expect(card!.elementType).toBe('authored');
|
|
679
|
+
const header = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'CardHeader');
|
|
680
|
+
expect(header).toBeDefined();
|
|
681
|
+
expect(header!.elementType).toBe('authored');
|
|
682
|
+
const body = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'CardBody');
|
|
683
|
+
expect(body).toBeDefined();
|
|
684
|
+
expect(body!.elementType).toBe('authored');
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
describe('Markdown inside JSX', () => {
|
|
689
|
+
test('Markdown heading inside JSX component', () => {
|
|
690
|
+
const doc = parse('<Callout>\n\n## Warning\n\nBe careful\n\n</Callout>\n');
|
|
691
|
+
const callout = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Callout');
|
|
692
|
+
expect(callout).toBeDefined();
|
|
693
|
+
const h2 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'h2');
|
|
694
|
+
expect(h2).toBeDefined();
|
|
695
|
+
const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
|
|
696
|
+
expect(p).toBeDefined();
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe('Multiple inline JSX in paragraph', () => {
|
|
701
|
+
test('multiple JSX elements in same paragraph', () => {
|
|
702
|
+
const doc = parse('<Badge>A</Badge> and <Badge>B</Badge>\n');
|
|
703
|
+
const badges = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'Badge');
|
|
704
|
+
expect(badges.length).toBe(2);
|
|
705
|
+
const texts = badges.map(b => b.childNodes.find(c => c.type === 'text')?.raw);
|
|
706
|
+
expect(texts).toStrictEqual(['A', 'B']);
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe('Empty open/close tags (no children)', () => {
|
|
711
|
+
test('element with explicit closing tag but no children', () => {
|
|
712
|
+
const doc = parse('<div></div>\n');
|
|
713
|
+
const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
|
|
714
|
+
expect(div).toBeDefined();
|
|
715
|
+
expect(div!.elementType).toBe('html');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('authored component with explicit closing tag but no children', () => {
|
|
719
|
+
const doc = parse('<Container></Container>\n');
|
|
720
|
+
const el = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Container');
|
|
721
|
+
expect(el).toBeDefined();
|
|
722
|
+
expect(el!.elementType).toBe('authored');
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
describe('Expression attribute with complex value', () => {
|
|
727
|
+
test('ternary expression in attribute value', () => {
|
|
728
|
+
const doc = parse('<Component value={isActive ? "yes" : "no"} />\n');
|
|
729
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Component');
|
|
730
|
+
expect(startTag).toBeDefined();
|
|
731
|
+
const attr = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'value');
|
|
732
|
+
expect(attr).toBeDefined();
|
|
733
|
+
expect(attr!.isDynamicValue).toBe(true);
|
|
734
|
+
expect(attr!.value.raw).toBe('isActive ? "yes" : "no"');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('array expression in attribute value', () => {
|
|
738
|
+
const doc = parse('<Component items={[1, 2, 3]} />\n');
|
|
739
|
+
const startTag = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Component');
|
|
740
|
+
expect(startTag).toBeDefined();
|
|
741
|
+
const attr = startTag!.attributes.find(a => a.type === 'attr' && a.name.raw === 'items');
|
|
742
|
+
expect(attr).toBeDefined();
|
|
743
|
+
expect(attr!.isDynamicValue).toBe(true);
|
|
744
|
+
expect(attr!.value.raw).toBe('[1, 2, 3]');
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
describe('IDL attribute name conversion', () => {
|
|
749
|
+
test('className is kept as-is (IDL resolution handled by ml-core)', () => {
|
|
750
|
+
const doc = parse('<div className="test">text</div>\n');
|
|
751
|
+
const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
|
|
752
|
+
expect(div).toBeDefined();
|
|
753
|
+
const attr = div!.attributes.find(a => a.type === 'attr' && a.name.raw === 'className');
|
|
754
|
+
expect(attr).toBeDefined();
|
|
755
|
+
// potentialName is not set by the parser; IDL resolution is handled by ml-core's useIDLAttributeNames
|
|
756
|
+
expect(attr!.potentialName).toBeUndefined();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test('htmlFor is kept as-is (IDL resolution handled by ml-core)', () => {
|
|
760
|
+
const doc = parse('<label htmlFor="input-id">Label</label>\n');
|
|
761
|
+
const label = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'label');
|
|
762
|
+
expect(label).toBeDefined();
|
|
763
|
+
const attr = label!.attributes.find(a => a.type === 'attr' && a.name.raw === 'htmlFor');
|
|
764
|
+
expect(attr).toBeDefined();
|
|
765
|
+
// potentialName is not set by the parser; IDL resolution is handled by ml-core's useIDLAttributeNames
|
|
766
|
+
expect(attr!.potentialName).toBeUndefined();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test('class in JSX is kept as-is (IDL resolution handled by ml-core)', () => {
|
|
770
|
+
const doc = parse('<div class="test">text</div>\n');
|
|
771
|
+
const div = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'div');
|
|
772
|
+
expect(div).toBeDefined();
|
|
773
|
+
const attr = div!.attributes.find(a => a.type === 'attr' && a.name.raw === 'class');
|
|
774
|
+
expect(attr).toBeDefined();
|
|
775
|
+
// candidate is not set by the parser; IDL resolution is handled by ml-core's useIDLAttributeNames
|
|
776
|
+
expect(attr!.candidate).toBeUndefined();
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
describe('MDX Markdown elements', () => {
|
|
781
|
+
test('unordered list in MDX', () => {
|
|
782
|
+
const doc = parse('- item 1\n- item 2\n');
|
|
783
|
+
const ul = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'ul');
|
|
784
|
+
expect(ul).toBeDefined();
|
|
785
|
+
const lis = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'li');
|
|
786
|
+
expect(lis.length).toBe(2);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test('thematicBreak in MDX (with content around it to avoid frontmatter)', () => {
|
|
790
|
+
const doc = parse('Text above\n\n---\n\nText below\n');
|
|
791
|
+
const hr = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'hr');
|
|
792
|
+
expect(hr).toBeDefined();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test('hard line break in MDX (two trailing spaces)', () => {
|
|
796
|
+
const doc = parse('line one \nline two\n');
|
|
797
|
+
const br = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'br');
|
|
798
|
+
expect(br).toBeDefined();
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
describe('List with JSX content', () => {
|
|
803
|
+
test('list items containing JSX components', () => {
|
|
804
|
+
const doc = parse('- <Badge>item 1</Badge>\n- <Badge>item 2</Badge>\n');
|
|
805
|
+
const ul = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'ul');
|
|
806
|
+
expect(ul).toBeDefined();
|
|
807
|
+
const badges = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'Badge');
|
|
808
|
+
expect(badges.length).toBe(2);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
describe('Heading with JSX', () => {
|
|
813
|
+
test('heading containing inline JSX component', () => {
|
|
814
|
+
const doc = parse('# Hello <Badge>New</Badge>\n');
|
|
815
|
+
const h1 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'h1');
|
|
816
|
+
expect(h1).toBeDefined();
|
|
817
|
+
const badge = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Badge');
|
|
818
|
+
expect(badge).toBeDefined();
|
|
819
|
+
expect(badge!.elementType).toBe('authored');
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
describe('Deep nesting JSX', () => {
|
|
824
|
+
test('three levels of nested JSX components', () => {
|
|
825
|
+
const doc = parse('<Level1>\n <Level2>\n <Level3>deep</Level3>\n </Level2>\n</Level1>\n');
|
|
826
|
+
const l1 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Level1');
|
|
827
|
+
const l2 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Level2');
|
|
828
|
+
const l3 = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Level3');
|
|
829
|
+
expect(l1).toBeDefined();
|
|
830
|
+
expect(l2).toBeDefined();
|
|
831
|
+
expect(l3).toBeDefined();
|
|
832
|
+
expect(l1!.elementType).toBe('authored');
|
|
833
|
+
expect(l2!.elementType).toBe('authored');
|
|
834
|
+
expect(l3!.elementType).toBe('authored');
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
describe('Consecutive expressions', () => {
|
|
839
|
+
test('multiple flow expressions in sequence', () => {
|
|
840
|
+
const doc = parse('{a}\n\n{b}\n\n{c}\n');
|
|
841
|
+
const exprs = doc.nodeList.filter(n => n?.type === 'psblock' && n.raw.startsWith('{'));
|
|
842
|
+
expect(exprs.length).toBe(3);
|
|
843
|
+
expect(exprs[0].raw).toBe('{a}');
|
|
844
|
+
expect(exprs[1].raw).toBe('{b}');
|
|
845
|
+
expect(exprs[2].raw).toBe('{c}');
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
describe('Empty expression', () => {
|
|
850
|
+
test('expression with only a comment', () => {
|
|
851
|
+
const doc = parse('{/* empty */}\n');
|
|
852
|
+
const expr = doc.nodeList.find(n => n?.type === 'psblock');
|
|
853
|
+
expect(expr).toBeDefined();
|
|
854
|
+
expect(expr!.raw).toBe('{/* empty */}');
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
describe('GFM autolink with JSX', () => {
|
|
859
|
+
test('autolink URL and JSX component in same paragraph', () => {
|
|
860
|
+
const doc = parse('Visit https://example.com and <Badge>click</Badge>\n');
|
|
861
|
+
const badge = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'Badge');
|
|
862
|
+
expect(badge).toBeDefined();
|
|
863
|
+
const a = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'a');
|
|
864
|
+
expect(a).toBeDefined();
|
|
865
|
+
const href = a!.attributes.find(attr => attr.type === 'attr' && attr.name.raw === 'href');
|
|
866
|
+
expect(href).toBeDefined();
|
|
867
|
+
expect(href!.value.raw).toBe('https://example.com');
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
describe('flattenMdastChildren: expression-only paragraph', () => {
|
|
872
|
+
test('paragraph containing only an expression is unwrapped (no <p>)', () => {
|
|
873
|
+
const doc = parse('{variable}\n');
|
|
874
|
+
const expr = doc.nodeList.find(n => n?.type === 'psblock');
|
|
875
|
+
expect(expr).toBeDefined();
|
|
876
|
+
const p = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'p');
|
|
877
|
+
expect(p).toBeUndefined();
|
|
878
|
+
});
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
describe('GFM extensions', () => {
|
|
882
|
+
test('GFM table produces table>tr>th/td elements', () => {
|
|
883
|
+
const doc = parse('| A | B |\n| - | - |\n| 1 | 2 |\n');
|
|
884
|
+
const table = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'table');
|
|
885
|
+
expect(table).toBeDefined();
|
|
886
|
+
const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
|
|
887
|
+
expect(ths.length).toBe(2);
|
|
888
|
+
const tds = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
|
|
889
|
+
expect(tds.length).toBe(2);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('GFM table header cells contain correct text', () => {
|
|
893
|
+
const doc = parse('| Name | Age |\n| - | - |\n| Alice | 30 |\n');
|
|
894
|
+
const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
|
|
895
|
+
expect(ths.length).toBe(2);
|
|
896
|
+
const thTexts = ths.map(th => th.childNodes.find(c => c.type === 'text')?.raw);
|
|
897
|
+
expect(thTexts).toStrictEqual(['Name', 'Age']);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test('GFM table with only header row has 0 td cells', () => {
|
|
901
|
+
const doc = parse('| A | B |\n| - | - |\n');
|
|
902
|
+
const ths = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'th');
|
|
903
|
+
expect(ths.length).toBe(2);
|
|
904
|
+
const tds = doc.nodeList.filter(n => n?.type === 'starttag' && n.nodeName === 'td');
|
|
905
|
+
expect(tds.length).toBe(0);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test('GFM strikethrough becomes <del> element', () => {
|
|
909
|
+
const doc = parse('~~deleted~~\n');
|
|
910
|
+
const del = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'del');
|
|
911
|
+
expect(del).toBeDefined();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test('GFM strikethrough contains correct text content', () => {
|
|
915
|
+
const doc = parse('~~deleted text~~\n');
|
|
916
|
+
const del = doc.nodeList.find(n => n?.type === 'starttag' && n.nodeName === 'del');
|
|
917
|
+
expect(del).toBeDefined();
|
|
918
|
+
const text = del!.childNodes.find(c => c.type === 'text');
|
|
919
|
+
expect(text).toBeDefined();
|
|
920
|
+
expect(text!.raw).toBe('deleted text');
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
});
|