@mintlify/cli 4.0.1743 → 4.0.1744

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.
@@ -1,6 +1,6 @@
1
1
  import { getAST } from '@mintlify/common';
2
2
  import fs from 'fs-extra';
3
- import fsp from 'node:fs/promises';
3
+ import fsSync from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
 
@@ -84,6 +84,128 @@ describe('validate rules', () => {
84
84
  expect(content).toContain('```html\n<br>\n```');
85
85
  });
86
86
 
87
+ it('removes a first heading that matches the title, except in custom mode', async () => {
88
+ write(dir, {
89
+ 'index.mdx': '---\ntitle: Hello World\n---\n\n# Hello World\n\nBody\n',
90
+ 'custom.mdx': '---\ntitle: Hello\nmode: custom\n---\n\n# Hello\n',
91
+ 'other.mdx': '---\ntitle: Hello\n---\n\n# Different\n',
92
+ });
93
+
94
+ const result = await check(dir, { fix: true });
95
+
96
+ expect(result.filesFixed).toEqual(['index.mdx']);
97
+ expect(read('index.mdx')).toBe('---\ntitle: Hello World\n---\n\nBody\n');
98
+ expect(read('custom.mdx')).toContain('# Hello');
99
+ });
100
+
101
+ it('adds an import for a snippet and reports components it cannot find', async () => {
102
+ write(dir, {
103
+ 'snippets/Widget.jsx': 'export const Widget = () => <div />;\n',
104
+ 'index.mdx':
105
+ '---\ntitle: Home\n---\n\n<Widget />\n\n<Missing />\n\n<Card title="x">ok</Card>\n',
106
+ });
107
+
108
+ const result = await check(dir, { fix: true });
109
+
110
+ expect(read('index.mdx')).toContain('import { Widget } from "/snippets/Widget.jsx";');
111
+ expect(result.issues.map(({ message }) => message)).toEqual([
112
+ expect.stringContaining('<Missing>'),
113
+ ]);
114
+ });
115
+
116
+ it('adds an import for a tsx snippet', async () => {
117
+ write(dir, {
118
+ 'snippets/Widget.tsx': 'export const Widget = ({ label }: { label: string }) => <div />;\n',
119
+ 'index.mdx': '---\ntitle: Home\n---\n\n<Widget />\n',
120
+ });
121
+
122
+ const result = await check(dir, { rules: ['unknown-component'], fix: true });
123
+
124
+ expect(read('index.mdx')).toContain('import { Widget } from "/snippets/Widget.tsx";');
125
+ expect(result.issues).toEqual([]);
126
+ });
127
+
128
+ it.each([
129
+ ['export { Widget } from "/snippets/impl.jsx";'],
130
+ ['import { Widget } from "/snippets/impl.jsx";\nexport { Widget };'],
131
+ ])('imports a re-exported component by name: %s', async (reexport) => {
132
+ write(dir, {
133
+ 'snippets/impl.jsx': 'export const Widget = () => <div />;\n',
134
+ 'snippets/Widget.jsx': `${reexport}\nexport default function Other() { return null; }\n`,
135
+ 'index.mdx': '---\ntitle: Home\n---\n\n<Widget />\n',
136
+ });
137
+
138
+ await check(dir, { rules: ['unknown-component'], fix: true });
139
+
140
+ expect(read('index.mdx')).toContain('import { Widget } from "/snippets/Widget.jsx";');
141
+ });
142
+
143
+ it('adds every missing snippet import in one run', async () => {
144
+ const names = ['One', 'Two', 'Three', 'Four', 'Five'];
145
+ write(dir, {
146
+ ...Object.fromEntries(
147
+ names.map((name) => [`snippets/${name}.jsx`, `export const ${name} = () => <div />;\n`])
148
+ ),
149
+ 'index.mdx': `---\ntitle: Home\n---\n\n${names.map((name) => `<${name} />`).join('\n\n')}\n`,
150
+ });
151
+
152
+ const result = await check(dir, { fix: true });
153
+
154
+ expect(result.issues).toEqual([]);
155
+ for (const name of names) {
156
+ expect(read('index.mdx')).toContain(`import { ${name} } from "/snippets/${name}.jsx";`);
157
+ }
158
+ });
159
+
160
+ it('imports a component exported only as the default through a specifier', async () => {
161
+ write(dir, {
162
+ 'snippets/Widget.jsx': 'const Widget = () => <div />;\nexport { Widget as default };\n',
163
+ 'snippets/Other.jsx': 'const Hidden = () => <div />;\nexport { Hidden };\n',
164
+ 'index.mdx': '---\ntitle: Home\n---\n\n<Widget />\n\n<Other />\n',
165
+ });
166
+
167
+ const result = await check(dir, { rules: ['unknown-component'], fix: true });
168
+
169
+ const content = read('index.mdx');
170
+ expect(content).toContain('import Widget from "/snippets/Widget.jsx";');
171
+ expect(content).not.toContain('Other.jsx');
172
+ expect(result.issues).toEqual([
173
+ expect.objectContaining({ message: expect.stringContaining('<Other>'), fixable: false }),
174
+ ]);
175
+ });
176
+
177
+ it('treats names bound by destructuring exports as declared', async () => {
178
+ write(dir, {
179
+ 'snippets/Widget.jsx': 'export const Widget = () => <div />;\n',
180
+ 'snippets/Panel.jsx': 'export const Panel = () => <div />;\n',
181
+ 'index.mdx':
182
+ '---\ntitle: Home\n---\n\nexport const { Widget, inner: { Panel = null }, ...Rest } = {};\nexport const [Gallery] = [];\n\n<Widget />\n\n<Panel />\n\n<Rest />\n\n<Gallery />\n',
183
+ });
184
+
185
+ expect((await check(dir, { rules: ['unknown-component'] })).issues).toEqual([]);
186
+ });
187
+
188
+ it('uses the default export when the name is only exported under an alias', async () => {
189
+ write(dir, {
190
+ 'snippets/Widget.jsx':
191
+ 'const Widget = () => <div />;\nexport { Widget as Renamed };\nexport default Widget;\n',
192
+ 'index.mdx': '---\ntitle: Home\n---\n\n<Widget />\n',
193
+ });
194
+
195
+ await check(dir, { fix: true });
196
+
197
+ expect(read('index.mdx')).toContain('import Widget from "/snippets/Widget.jsx";');
198
+ });
199
+
200
+ it('treats a named default export as declared', async () => {
201
+ write(dir, {
202
+ 'index.mdx':
203
+ '---\ntitle: Home\n---\n\nexport default function Hero() {\n return <div />;\n}\n\n<Hero />\n',
204
+ });
205
+
206
+ expect((await check(dir, {})).issues).toEqual([]);
207
+ });
208
+
87
209
  it('skips void tags inside block-quoted code fences', async () => {
88
210
  write(dir, {
89
211
  'index.mdx': '---\ntitle: Home\n---\n\n> ~~~html\n> <br>\n> ~~~\n\nline<br>\n',
@@ -99,6 +221,120 @@ describe('validate rules', () => {
99
221
  expect(read('other.mdx')).toContain('after<br />');
100
222
  });
101
223
 
224
+ it('keeps a duplicate heading that reference-style links point to', async () => {
225
+ write(dir, {
226
+ 'index.mdx': '---\ntitle: Quickstart\n---\n\n# Quickstart\n\nBody\n',
227
+ 'other.mdx': '---\ntitle: Other\n---\n\nSee [top][start].\n\n[start]: /index#quickstart\n',
228
+ });
229
+
230
+ const result = await check(dir, { fix: true });
231
+
232
+ expect(read('index.mdx')).toContain('# Quickstart');
233
+ expect(result.issues.map(({ message }) => message)).toEqual([
234
+ expect.stringContaining('links point to #quickstart'),
235
+ ]);
236
+ });
237
+
238
+ it.each([
239
+ ["The User's Settings", '"The User\'s Settings"', "the-user's-settings", 'the-users-settings'],
240
+ ["The User's Settings", '"The User\'s Settings"', 'the-user’s-settings', 'the-user’s-settings'],
241
+ [
242
+ "The User's Settings",
243
+ '"The User\'s Settings"',
244
+ 'the-user%E2%80%99s-settings',
245
+ 'the-user’s-settings',
246
+ ],
247
+ ['"Pending" Status', '\'"Pending" Status\'', 'pending-status', 'pending-status'],
248
+ ['"Pending" Status', '\'"Pending" Status\'', '“pending”-status', '“pending”-status'],
249
+ ])(
250
+ 'keeps a duplicate heading with quotes that a link points to: %s, title %s, link #%s',
251
+ async (heading, title, link, anchor) => {
252
+ write(dir, {
253
+ 'settings.mdx': `---\ntitle: ${title}\n---\n\n# ${heading}\n\nBody\n`,
254
+ 'other.mdx': `---\ntitle: Other\n---\n\nSee [settings](/settings#${link}).\n`,
255
+ });
256
+
257
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
258
+
259
+ expect(read('settings.mdx')).toContain(`# ${heading}`);
260
+ expect(result.issues.map(({ message }) => message)).toEqual([
261
+ expect.stringContaining(`links point to #${anchor}`),
262
+ ]);
263
+ }
264
+ );
265
+
266
+ it('reads the heading text the way the table of contents does', async () => {
267
+ write(dir, { 'index.mdx': '---\ntitle: Install\n---\n\n# Install {/* draft */}\n\nBody\n' });
268
+
269
+ const { issues } = await check(dir, { rules: ['duplicate-h1'] });
270
+
271
+ expect(issues).toEqual([expect.objectContaining({ rule: 'duplicate-h1', fixable: true })]);
272
+ });
273
+
274
+ it('removes a duplicate heading with quotes that no link points to', async () => {
275
+ write(dir, {
276
+ 'settings.mdx': '---\ntitle: "The User\'s Settings"\n---\n\n# The User\'s Settings\n\nBody\n',
277
+ 'other.mdx': '---\ntitle: Other\n---\n\nSee [settings](/settings#other-heading).\n',
278
+ });
279
+
280
+ await check(dir, { rules: ['duplicate-h1'], fix: true });
281
+
282
+ expect(read('settings.mdx')).not.toContain("# The User's Settings");
283
+ });
284
+
285
+ it('reports and imports components used inside expressions', async () => {
286
+ write(dir, {
287
+ 'snippets/Widget.jsx': 'export const Widget = () => <div />;\n',
288
+ 'index.mdx':
289
+ '---\ntitle: Home\n---\n\n{open && <Widget />}\n\n{items.map((Item) => <Item />)}\n\nText {flag ? <Missing /> : null} and {Math.max(1, 2)}.\n',
290
+ });
291
+
292
+ const { issues } = await check(dir, { rules: ['unknown-component'] });
293
+ expect(
294
+ issues.map(({ line, column, message, fixable }) => [line, column, message, fixable])
295
+ ).toEqual([
296
+ [5, 10, '<Widget> is not built in, imported or exported on this page', true],
297
+ [9, 14, '<Missing> is not built in, imported or exported on this page', false],
298
+ ]);
299
+
300
+ await check(dir, { rules: ['unknown-component'], fix: true });
301
+ expect(read('index.mdx')).toContain('import { Widget } from "/snippets/Widget.jsx";');
302
+ });
303
+
304
+ it('reports components used inside attribute values', async () => {
305
+ write(dir, {
306
+ 'index.mdx':
307
+ '---\ntitle: Home\n---\n\n<Card title="a" icon={<Glyph />}>x</Card>\n\n<Card {...{ icon: <Dot /> }}>y</Card>\n',
308
+ });
309
+
310
+ const { issues } = await check(dir, { rules: ['unknown-component'] });
311
+
312
+ expect(issues.map(({ line, column, message }) => [line, column, message])).toEqual([
313
+ [5, 23, '<Glyph> is not built in, imported or exported on this page'],
314
+ [7, 19, '<Dot> is not built in, imported or exported on this page'],
315
+ ]);
316
+ });
317
+
318
+ it('does not flag imported or exported components', async () => {
319
+ write(dir, {
320
+ 'index.mdx':
321
+ '---\ntitle: Home\n---\n\nimport Thing from "/snippets/Thing.jsx";\nimport { A as B } from "/snippets/b.jsx";\n\nexport const Local = () => <p />;\n\n<Thing /><B /><Local /><Color.Row />\n',
322
+ });
323
+
324
+ expect((await check(dir, {})).issues).toEqual([]);
325
+ });
326
+
327
+ it('reports math KaTeX cannot render', async () => {
328
+ write(dir, {
329
+ 'index.mdx': '---\ntitle: Home\n---\n\nGood $x^2$ bad $\\frac{1$\n',
330
+ });
331
+
332
+ const { issues } = await check(dir, {});
333
+
334
+ expect(issues.map(({ rule }) => rule)).toEqual(['katex']);
335
+ expect(issues[0]?.fixable).toBe(false);
336
+ });
337
+
102
338
  it('keeps an info-string fence line from closing an open fence', async () => {
103
339
  write(dir, {
104
340
  'index.mdx':
@@ -145,6 +381,174 @@ describe('validate rules', () => {
145
381
  expect(content).toContain('`` a`<br>`b ``');
146
382
  });
147
383
 
384
+ it('reports but does not remove a duplicate heading with a custom id', async () => {
385
+ write(dir, {
386
+ 'index.mdx': '---\ntitle: Hello\n---\n\n# Hello {#intro}\n\nBody\n',
387
+ });
388
+
389
+ const result = await check(dir, { fix: true });
390
+
391
+ expect(result.filesFixed).toEqual([]);
392
+ expect(result.issues).toEqual([
393
+ expect.objectContaining({ rule: 'duplicate-h1', fixable: false }),
394
+ ]);
395
+ expect(read('index.mdx')).toContain('# Hello {#intro}');
396
+ });
397
+
398
+ it('only treats a top-level heading as the first heading', async () => {
399
+ write(dir, {
400
+ 'index.mdx': '---\ntitle: Hello\n---\n\n<Tab title="a">\n\n# Hello\n\n</Tab>\n',
401
+ 'imports.mdx':
402
+ '---\ntitle: Hello\n---\n\nimport { A } from "/snippets/a.jsx";\n\n# Hello\n\n<A />\n',
403
+ });
404
+
405
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
406
+
407
+ expect(result.filesFixed).toEqual(['imports.mdx']);
408
+ expect(read('index.mdx')).toContain('# Hello');
409
+ expect(read('imports.mdx')).not.toContain('# Hello');
410
+ });
411
+
412
+ it('reads imports from the ESM tree, including side-effect imports', async () => {
413
+ write(dir, {
414
+ 'index.mdx':
415
+ '---\ntitle: Home\n---\n\nimport "./styles.css";\nimport Thing from "/snippets/Thing.jsx";\nimport * as Lib from "/snippets/lib.js";\nexport { Other } from "/snippets/other.jsx";\nexport function Fn() { return null; }\n\n<Thing /><Lib.X /><Other /><Fn />\n',
416
+ });
417
+
418
+ expect((await check(dir, { rules: ['unknown-component'] })).issues).toEqual([]);
419
+ });
420
+
421
+ it('leaves a blank line after an inserted import wherever it goes', async () => {
422
+ const widget = 'import { Widget } from "/snippets/Widget.jsx";';
423
+ write(dir, {
424
+ 'snippets/Widget.jsx': 'export const Widget = () => <div />;\n',
425
+ 'tight.mdx': '---\ntitle: Tight\n---\n<Widget />\n',
426
+ 'top.mdx': '<Widget />\n',
427
+ 'imports.mdx':
428
+ '---\ntitle: Imports\n---\nexport const A = () => null;\n\n<A />\n\n<Widget />\n',
429
+ });
430
+
431
+ const result = await check(dir, { rules: ['unknown-component'], fix: true });
432
+
433
+ expect(result.issues).toEqual([]);
434
+ expect(read('tight.mdx')).toBe(`---\ntitle: Tight\n---\n\n${widget}\n\n<Widget />\n`);
435
+ expect(read('top.mdx')).toBe(`${widget}\n\n<Widget />\n`);
436
+ expect(read('imports.mdx')).toBe(
437
+ `---\ntitle: Imports\n---\n\n${widget}\n\nexport const A = () => null;\n\n<A />\n\n<Widget />\n`
438
+ );
439
+ });
440
+
441
+ it('inserts snippet imports after TOML frontmatter', async () => {
442
+ write(dir, {
443
+ 'snippets/Widget.jsx': 'export const Widget = () => <div />;\n',
444
+ 'index.mdx': "+++\ntitle = 'Home'\n+++\n\n<Widget />\n",
445
+ });
446
+
447
+ await check(dir, { rules: ['unknown-component'], fix: true });
448
+
449
+ expect(read('index.mdx')).toBe(
450
+ '+++\ntitle = \'Home\'\n+++\n\nimport { Widget } from "/snippets/Widget.jsx";\n\n<Widget />\n'
451
+ );
452
+ });
453
+
454
+ it('prefers a named import when the snippet exports the component by name', async () => {
455
+ write(dir, {
456
+ 'snippets/Named.jsx':
457
+ 'export const Named = () => <div />;\nexport default function Other() { return null; }\n',
458
+ 'snippets/Listed.jsx':
459
+ 'const Listed = () => <div />;\nexport { Listed };\nexport default {};\n',
460
+ 'snippets/Default.jsx': 'export default function Default() { return null; }\n',
461
+ 'index.mdx': '---\ntitle: Home\n---\n\n<Named />\n\n<Listed />\n\n<Default />\n',
462
+ });
463
+
464
+ await check(dir, { rules: ['unknown-component'], fix: true });
465
+
466
+ const content = read('index.mdx');
467
+ expect(content).toContain('import { Named } from "/snippets/Named.jsx";');
468
+ expect(content).toContain('import { Listed } from "/snippets/Listed.jsx";');
469
+ expect(content).toContain('import Default from "/snippets/Default.jsx";');
470
+ });
471
+
472
+ it('skips price-like spans, which the site renders as text', async () => {
473
+ write(dir, {
474
+ 'index.mdx': '---\ntitle: Home\n---\n\nPlans cost $5 & up, or $10 a seat.\n',
475
+ });
476
+
477
+ expect((await check(dir, {})).issues).toEqual([]);
478
+ });
479
+
480
+ it('tags each issue with its rule severity', async () => {
481
+ write(dir, {
482
+ 'void.mdx': '---\ntitle: Void\n---\n\nline<br>\n',
483
+ 'index.mdx':
484
+ '---\ntitle: Home\n---\n\n# Home\n\n<Missing />\n\nbad $\\frac{1$ and $5 and $10\n',
485
+ });
486
+
487
+ const { issues } = await check(dir, {
488
+ rules: ['void-tags', 'duplicate-h1', 'unknown-component', 'katex'],
489
+ });
490
+ const severities = Object.fromEntries(issues.map(({ rule, severity }) => [rule, severity]));
491
+
492
+ expect(severities).toEqual({
493
+ 'void-tags': 'error',
494
+ 'duplicate-h1': 'warning',
495
+ 'unknown-component': 'warning',
496
+ katex: 'warning',
497
+ });
498
+ expect(hasErrors(issues)).toBe(true);
499
+ expect(hasErrors(issues.filter(({ severity }) => severity === 'warning'))).toBe(false);
500
+ });
501
+
502
+ it('previews the fixes --fix applies without writing, accounting for interacting fixes', async () => {
503
+ const page = '---\ntitle: Hello\n---\n\n# Hello\n\nline<br>\n\n<Missing />\n\nbad $\\frac{1$\n';
504
+ write(dir, { 'index.mdx': page });
505
+
506
+ const plain = await check(dir, {});
507
+ expect(read('index.mdx')).toBe(page);
508
+ expect(plain.fixed).toEqual([]);
509
+ expect(plain.filesFixed).toEqual([]);
510
+ const preview = plain.issues.map(({ rule, line, fixable, fix }) => ({
511
+ rule,
512
+ line,
513
+ fixable,
514
+ fix: fix && { before: page.slice(fix.start, fix.end), after: fix.text },
515
+ }));
516
+ expect(preview).toEqual([
517
+ {
518
+ rule: 'duplicate-h1',
519
+ line: 5,
520
+ fixable: true,
521
+ fix: { before: '# Hello\n\n', after: '' },
522
+ },
523
+ { rule: 'void-tags', line: 7, fixable: true, fix: { before: '<br>', after: '<br />' } },
524
+ { rule: 'unknown-component', line: 9, fixable: false, fix: undefined },
525
+ { rule: 'katex', line: 11, fixable: false, fix: undefined },
526
+ ]);
527
+
528
+ const fixed = await check(dir, { fix: true });
529
+ expect(
530
+ fixed.fixed.map(({ rule, line, before, after }) => ({ rule, line, before, after }))
531
+ ).toEqual(
532
+ preview.filter(({ fix }) => fix).map(({ rule, line, fix }) => ({ rule, line, ...fix }))
533
+ );
534
+ expect(fixed.issues.map(({ rule }) => rule)).toEqual(['unknown-component', 'katex']);
535
+ expect(read('index.mdx')).toBe(
536
+ '---\ntitle: Hello\n---\n\nline<br />\n\n<Missing />\n\nbad $\\frac{1$\n'
537
+ );
538
+ });
539
+
540
+ it('accepts every component the build keeps or rewrites', async () => {
541
+ write(dir, {
542
+ 'snippets/part.mdx': 'Part\n',
543
+ 'index.mdx':
544
+ '---\ntitle: Home\n---\n\n<Snippet file="part.mdx" />\n\n<Example>x</Example>\n\n<Param name="a">x</Param>\n\n<ApiPlayground />\n\n<MDXContentController />\n\n<Color>\n <Color.Row title="a">\n <Color.Item name="b" value="#fff" />\n </Color.Row>\n</Color>\n\n<Tree>\n <Tree.Folder name="a">\n <Tree.File name="b" />\n </Tree.Folder>\n</Tree>\n\n<Color.Nope />\n',
545
+ });
546
+
547
+ const { issues } = await check(dir, { rules: ['unknown-component'] });
548
+
549
+ expect(issues.map(({ message }) => message)).toEqual([expect.stringContaining('<Color.Nope>')]);
550
+ });
551
+
148
552
  it('leaves escaped void tags alone', async () => {
149
553
  write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\nText \\<br> and \\<img src="/a.png">\n' });
150
554
 
@@ -181,6 +585,16 @@ describe('validate rules', () => {
181
585
  expect(read('index.mdx')).toBe(page);
182
586
  });
183
587
 
588
+ it('parses headings with a spaced custom id and keeps their fixes', async () => {
589
+ write(dir, {
590
+ 'index.mdx': '---\ntitle: Hello\n---\n\n# Hello\n\n## Setup { #setup }\n\nBody\n',
591
+ });
592
+
593
+ const { issues } = await check(dir, {});
594
+
595
+ expect(issues).toEqual([expect.objectContaining({ rule: 'duplicate-h1', fixable: true })]);
596
+ });
597
+
184
598
  it('accepts <MDX> fragments the build preprocesses', async () => {
185
599
  write(dir, {
186
600
  'index.mdx':
@@ -194,12 +608,12 @@ describe('validate rules', () => {
194
608
  write(dir, {
195
609
  '.mintignore': 'drafts/\n',
196
610
  'openapi.yaml': 'openapi: [unclosed\n',
197
- 'drafts/wip.mdx': '---\ntitle: Wip\n---\n\nline<br>\n',
198
- 'SKILL.md': 'line<br>\n',
199
- 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
611
+ 'drafts/wip.mdx': '---\ntitle: Wip\n---\n\n<Missing />\n',
612
+ 'SKILL.md': '<Missing />\n',
613
+ 'index.mdx': '---\ntitle: Home\n---\n\n<Missing />\n',
200
614
  });
201
615
 
202
- const result = await check(dir, { rules: ['void-tags'] });
616
+ const result = await check(dir, { rules: ['unknown-component'] });
203
617
 
204
618
  expect(result.filesChecked).toBe(1);
205
619
  expect(result.issues.map(({ file }) => file)).toEqual(['index.mdx']);
@@ -269,6 +683,122 @@ describe('validate rules', () => {
269
683
  expect(issues.map(({ line }) => line)).toEqual([7, 11]);
270
684
  });
271
685
 
686
+ it('reports but does not remove a duplicate heading that other pages link to', async () => {
687
+ write(dir, {
688
+ 'guides/start.mdx': '---\ntitle: Quickstart\n---\n\n# Quickstart\n\nBody\n',
689
+ 'index.mdx': '---\ntitle: Home\n---\n\nSee [the start](/guides/start#quickstart).\n',
690
+ 'guides/other.mdx': '---\ntitle: Other\n---\n\nUnrelated [link](./start#install).\n',
691
+ 'plain.mdx': '---\ntitle: Plain\n---\n\n# Plain\n\nBody\n',
692
+ });
693
+
694
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
695
+
696
+ expect(result.filesFixed).toEqual(['plain.mdx']);
697
+ expect(read('guides/start.mdx')).toContain('# Quickstart');
698
+ expect(result.issues).toEqual([
699
+ expect.objectContaining({
700
+ file: 'guides/start.mdx',
701
+ fixable: false,
702
+ message: expect.stringContaining('#quickstart'),
703
+ }),
704
+ ]);
705
+ });
706
+
707
+ it('finds relative and same-page links to the heading anchor', async () => {
708
+ write(dir, {
709
+ 'guides/start.mdx':
710
+ '---\ntitle: Quickstart\n---\n\n# Quickstart\n\nBack to [top](#quickstart).\n',
711
+ 'guides/next.mdx': '---\ntitle: Next\n---\n\n# Next\n\n',
712
+ 'guides/after.mdx': '---\ntitle: After\n---\n\nRead <a href="next#next">next</a>.\n',
713
+ });
714
+
715
+ const { issues } = await check(dir, { rules: ['duplicate-h1'] });
716
+
717
+ expect(issues.map(({ file, fixable }) => [file, fixable])).toEqual([
718
+ ['guides/next.mdx', false],
719
+ ['guides/start.mdx', false],
720
+ ]);
721
+ });
722
+
723
+ it('ignores heading links inside code', async () => {
724
+ write(dir, {
725
+ 'guides/start.mdx': '---\ntitle: Quickstart\n---\n\n# Quickstart\n\nBody\n',
726
+ 'index.mdx':
727
+ '---\ntitle: Home\n---\n\n```md\n[start](/guides/start#quickstart)\n```\n\nUse `<a href="/guides/start#quickstart">`.\n',
728
+ });
729
+
730
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
731
+
732
+ expect(result.filesFixed).toEqual(['guides/start.mdx']);
733
+ });
734
+
735
+ it('keeps a duplicate homepage heading that a root hash link points to', async () => {
736
+ write(dir, {
737
+ 'index.mdx': '---\ntitle: Home\n---\n\n# Home\n\nBody\n',
738
+ 'guides/start.mdx': '---\ntitle: Start\n---\n\nBack [home](/#home).\n',
739
+ });
740
+
741
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
742
+
743
+ expect(result.filesFixed).toEqual([]);
744
+ expect(read('index.mdx')).toContain('# Home');
745
+ });
746
+
747
+ it('finds heading links that carry a query string', async () => {
748
+ write(dir, {
749
+ 'guides/start.mdx': '---\ntitle: Quickstart\n---\n\n# Quickstart\n\nBody\n',
750
+ 'index.mdx': '---\ntitle: Home\n---\n\nSee [the start](/guides/start?tab=api#quickstart).\n',
751
+ });
752
+
753
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
754
+
755
+ expect(result.filesFixed).toEqual([]);
756
+ expect(read('guides/start.mdx')).toContain('# Quickstart');
757
+ });
758
+
759
+ it('keeps a duplicate heading when links point to a later heading that would take its id', async () => {
760
+ write(dir, {
761
+ 'index.mdx': '---\ntitle: Setup\n---\n\n# Setup\n\n## Setup\n\nBody\n',
762
+ 'other.mdx': '---\ntitle: Other\n---\n\nSee [the details](/index#setup-2).\n',
763
+ });
764
+
765
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
766
+
767
+ expect(result.filesFixed).toEqual([]);
768
+ expect(read('index.mdx')).toContain('# Setup\n');
769
+ expect(result.issues).toEqual([
770
+ expect.objectContaining({ fixable: false, message: expect.stringContaining('#setup-2') }),
771
+ ]);
772
+ });
773
+
774
+ it('finds anchor links in string-literal expression hrefs', async () => {
775
+ write(dir, {
776
+ 'double.mdx': '---\ntitle: Double\n---\n\n# Double\n',
777
+ 'template.mdx': '---\ntitle: Template\n---\n\n# Template\n',
778
+ 'dynamic.mdx': '---\ntitle: Dynamic\n---\n\n# Dynamic\n',
779
+ 'links.mdx':
780
+ '---\ntitle: Links\n---\n\n<a href={"/double#double"}>a</a>\n\n<a href={`/template#template`}>b</a>\n\n<a href={`/dynamic#${slug}`}>c</a>\n',
781
+ });
782
+
783
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
784
+
785
+ expect(result.filesFixed).toEqual(['dynamic.mdx']);
786
+ expect(read('double.mdx')).toContain('# Double');
787
+ expect(read('template.mdx')).toContain('# Template');
788
+ });
789
+
790
+ it('finds anchor links in hrefs with spaces around the equals sign', async () => {
791
+ write(dir, {
792
+ 'guides/start.mdx': '---\ntitle: Quickstart\n---\n\n# Quickstart\n\nBody\n',
793
+ 'index.mdx': '---\ntitle: Home\n---\n\n<a href = "/guides/start#quickstart">Start</a>\n',
794
+ });
795
+
796
+ const result = await check(dir, { rules: ['duplicate-h1'], fix: true });
797
+
798
+ expect(result.filesFixed).toEqual([]);
799
+ expect(read('guides/start.mdx')).toContain('# Quickstart');
800
+ });
801
+
272
802
  it('keeps file order when linting many files concurrently', async () => {
273
803
  const names = Array.from(
274
804
  { length: 40 },
@@ -319,10 +849,21 @@ describe('validate rules', () => {
319
849
  );
320
850
  });
321
851
 
322
- it('lints snippets', async () => {
852
+ it('accepts pages the build parses, such as code inside <MDX>, without offering edits', async () => {
853
+ write(dir, {
854
+ 'index.mdx': '---\ntitle: Home\n---\n\n<MDX>\nif (a < b) { run() }\n</MDX>\n\n<Missing />\n',
855
+ });
856
+
857
+ const result = await check(dir, {});
858
+
859
+ expect(result.issues.map(({ rule }) => rule)).toEqual(['unknown-component']);
860
+ expect(result.issues[0]?.fixable).toBe(false);
861
+ });
862
+
863
+ it('lints snippets, skipping page-only rules', async () => {
323
864
  write(dir, {
324
865
  'index.mdx': '---\ntitle: Home\n---\n\nok\n',
325
- 'snippets/note.mdx': '---\ntitle: Same\n---\n\nline<br>\n',
866
+ 'snippets/note.mdx': '---\ntitle: Same\n---\n\n# Same\n\nline<br>\n\n<FromPage />\n',
326
867
  });
327
868
 
328
869
  const result = await check(dir, {});
@@ -332,6 +873,60 @@ describe('validate rules', () => {
332
873
  ]);
333
874
  });
334
875
 
876
+ it('orders findings on one line left to right across rules', async () => {
877
+ write(dir, {
878
+ 'index.mdx': '---\ntitle: Home\n---\n\nSee <Missing /> and a line break here <br>\n',
879
+ });
880
+
881
+ const result = await check(dir, { rules: ['void-tags', 'unknown-component'] });
882
+
883
+ expect(result.issues.map(({ rule, line, column }) => [rule, line, column])).toEqual([
884
+ ['unknown-component', 5, 5],
885
+ ['void-tags', 5, 39],
886
+ ]);
887
+ });
888
+
889
+ it('names leftover GitBook syntax even when the page does not parse', async () => {
890
+ write(dir, {
891
+ 'index.mdx': '---\ntitle: Home\n---\n\n{% hint style="info" %}\nRead this\n{% endhint %}\n',
892
+ });
893
+
894
+ const result = await check(dir, {});
895
+
896
+ expect(result.issues.map(({ rule, line }) => [rule, line])).toEqual([
897
+ ['foreign-syntax', 5],
898
+ ['parse-error', 5],
899
+ ]);
900
+ expect(result.issues[0]?.message).toContain('use <Info>');
901
+ });
902
+
903
+ it('skips foreign syntax inside JSX attribute and JavaScript strings', async () => {
904
+ write(dir, {
905
+ 'index.mdx':
906
+ '---\ntitle: Home\n---\n\n<Card title="{{ site.title }}" href="doc:intro" />\n\n{"{{ page.title }}"}\n\n{{ site.name }}\n\n[Intro](doc:intro)\n',
907
+ });
908
+
909
+ const { issues } = await check(dir, { rules: ['foreign-syntax'] });
910
+ const found = issues.filter(({ rule }) => rule === 'foreign-syntax');
911
+
912
+ expect(found.map(({ line, message }) => [line, message.split(' ')[0]])).toEqual([
913
+ [5, 'doc:intro'],
914
+ [9, '{{'],
915
+ [11, 'doc:intro'],
916
+ ]);
917
+ });
918
+
919
+ it('reports foreign syntax the build accepts without failing lint', async () => {
920
+ write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\n:::note\nRead this\n:::\n' });
921
+
922
+ const result = await check(dir, {});
923
+
924
+ expect(result.issues.map(({ rule, severity }) => [rule, severity])).toEqual([
925
+ ['foreign-syntax', 'warning'],
926
+ ]);
927
+ expect(hasErrors(result.issues)).toBe(false);
928
+ });
929
+
335
930
  it('rejects --files that name no page or snippet of the site', async () => {
336
931
  write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\nok\n', 'README.md': '# Readme\n' });
337
932
 
@@ -355,7 +950,7 @@ describe('validate rules', () => {
355
950
  'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
356
951
  'other.mdx': '---\ntitle: Other\n---\n\nline<br>\n',
357
952
  });
358
- const spy = vi.spyOn(fsp, 'readFile');
953
+ const spy = vi.spyOn(fsSync, 'readFileSync');
359
954
 
360
955
  const result = await check(dir, { rules: ['void-tags'], files: ['other.mdx'] });
361
956
 
@@ -366,6 +961,62 @@ describe('validate rules', () => {
366
961
  expect(result.issues.map(({ file }) => file)).toEqual(['other.mdx']);
367
962
  });
368
963
 
964
+ it('does no anchor reads when duplicate-h1 is disabled', async () => {
965
+ write(dir, {
966
+ 'index.mdx': '---\ntitle: Home\n---\n\n# Home\n',
967
+ 'other.mdx': '---\ntitle: Other\n---\n\nSee [home](/index#home).\n',
968
+ });
969
+ const spy = vi.spyOn(fsSync, 'readFileSync');
970
+
971
+ const result = await checkSite(dir, selectRules({ disable: ['duplicate-h1'] }), {
972
+ selected: new Set(['index.mdx']),
973
+ localSchema: false,
974
+ fix: false,
975
+ });
976
+
977
+ const reads = spy.mock.calls.map(([file]) => String(file));
978
+ spy.mockRestore();
979
+ expect(reads.some((file) => file.endsWith('other.mdx'))).toBe(false);
980
+ expect(result.diagnostics.filter(({ rule }) => rule === 'duplicate-h1')).toEqual([]);
981
+ });
982
+
983
+ it('collects no anchor links when no page has a duplicate first heading', async () => {
984
+ write(dir, {
985
+ 'index.mdx': '---\ntitle: Home\n---\n\n# Welcome\n',
986
+ 'other.mdx': '---\ntitle: Other\n---\n\nSee [home](/index#welcome).\n',
987
+ });
988
+ const spy = vi.spyOn(fsSync, 'readFileSync');
989
+
990
+ const result = await check(dir, { rules: ['duplicate-h1'], files: ['index.mdx'] });
991
+
992
+ const reads = spy.mock.calls.map(([file]) => String(file));
993
+ spy.mockRestore();
994
+ expect(reads.some((file) => file.endsWith('other.mdx'))).toBe(false);
995
+ expect(result.issues).toEqual([]);
996
+ });
997
+
998
+ it('reads every file once when duplicate-h1 runs on a selection', async () => {
999
+ write(dir, {
1000
+ 'index.mdx': '---\ntitle: Home\n---\n\n# Home\n',
1001
+ 'other.mdx': '---\ntitle: Other\n---\n\nSee [home](/index#home).\n',
1002
+ });
1003
+ const spy = vi.spyOn(fsSync, 'readFileSync');
1004
+
1005
+ const result = await check(dir, { rules: ['duplicate-h1'], files: ['index.mdx'] });
1006
+
1007
+ const reads = spy.mock.calls.map(([file]) => String(file));
1008
+ spy.mockRestore();
1009
+ expect(reads.filter((file) => file.endsWith('other.mdx'))).toHaveLength(1);
1010
+ expect(reads.filter((file) => file.endsWith('index.mdx'))).toHaveLength(1);
1011
+ expect(result.issues).toEqual([
1012
+ expect.objectContaining({
1013
+ file: 'index.mdx',
1014
+ fixable: false,
1015
+ message: expect.stringContaining('#home'),
1016
+ }),
1017
+ ]);
1018
+ });
1019
+
369
1020
  it('skips void tags in block-quoted fences and inline code', async () => {
370
1021
  write(dir, {
371
1022
  'index.mdx': '---\ntitle: Home\n---\n\n> Quote\n>\n> ```html\n> <br>\n> ```\n\nSee `<hr>`.\n',