@mintlify/cli 4.0.1740 → 4.0.1741

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.
@@ -366,6 +366,26 @@ describe('mint validate', () => {
366
366
  expect(processExitMock).toHaveBeenCalledWith(2);
367
367
  });
368
368
 
369
+ it('applies fixes with --fix and lists them', async () => {
370
+ writeSite({ 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n' });
371
+
372
+ const result = await report('validate', '--no-build', '--fix');
373
+
374
+ expect(fs.readFileSync(path.join(dir, 'index.mdx'), 'utf-8')).toContain('line<br />');
375
+ expect(result.fixed).toEqual([
376
+ {
377
+ rule: 'void-tags',
378
+ file: 'index.mdx',
379
+ line: 5,
380
+ column: 5,
381
+ before: '<br>',
382
+ after: '<br />',
383
+ },
384
+ ]);
385
+ expect(rules(result)).toEqual([]);
386
+ expect(processExitMock).toHaveBeenCalledWith(0);
387
+ });
388
+
369
389
  it('turns rules and groups on and off', async () => {
370
390
  writeSite({
371
391
  'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
@@ -1,6 +1,17 @@
1
- import { selectRules, UsageError } from '../src/validate/engine.js';
1
+ import fs from 'fs-extra';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { applyFixes, checkSite, selectRules, UsageError } from '../src/validate/engine.js';
2
7
  import { RULES } from '../src/validate/rules/index.js';
3
- import { GROUPS } from '../src/validate/types.js';
8
+ import {
9
+ GROUPS,
10
+ type Diagnostic,
11
+ type FileRule,
12
+ type Site,
13
+ type SiteRule,
14
+ } from '../src/validate/types.js';
4
15
 
5
16
  const ids = (rules: { id: string }[]) => rules.map(({ id }) => id);
6
17
 
@@ -44,3 +55,176 @@ describe('selectRules', () => {
44
55
  expect(new Set(ids(RULES)).size).toBe(RULES.length);
45
56
  });
46
57
  });
58
+
59
+ describe('applyFixes', () => {
60
+ let dir: string;
61
+
62
+ beforeEach(() => {
63
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-engine-'));
64
+ });
65
+
66
+ afterEach(() => {
67
+ fs.removeSync(dir);
68
+ });
69
+
70
+ const site = () => ({ baseDir: dir, files: [] }) as unknown as Site;
71
+
72
+ const diagnostic = (fix: Diagnostic['fix'], rule = 'r'): Diagnostic => ({
73
+ rule,
74
+ group: 'content',
75
+ severity: 'error',
76
+ file: 'a.mdx',
77
+ message: 'm',
78
+ fix,
79
+ });
80
+
81
+ it('applies fixes across files, skipping ones that overlap an applied fix', async () => {
82
+ fs.writeFileSync(path.join(dir, 'a.mdx'), 'one two three');
83
+ fs.writeFileSync(path.join(dir, 'b.json'), '{}');
84
+
85
+ const changes = await applyFixes(site(), [
86
+ diagnostic({ file: 'a.mdx', start: 0, end: 3, text: '1' }),
87
+ diagnostic({ file: 'a.mdx', start: 8, end: 13, text: '3' }),
88
+ diagnostic({ file: 'b.json', start: 1, end: 1, text: '"k":1' }),
89
+ // Overlaps the first fix, so it waits for the next pass
90
+ diagnostic({ file: 'a.mdx', start: 2, end: 5, text: 'x' }),
91
+ ]);
92
+
93
+ expect(fs.readFileSync(path.join(dir, 'a.mdx'), 'utf-8')).toBe('1 two 3');
94
+ expect(fs.readFileSync(path.join(dir, 'b.json'), 'utf-8')).toBe('{"k":1}');
95
+ expect(changes.map(({ file, before, after }) => [file, before, after])).toEqual([
96
+ ['a.mdx', 'one', '1'],
97
+ ['a.mdx', 'three', '3'],
98
+ ['b.json', '', '"k":1'],
99
+ ]);
100
+ });
101
+
102
+ it('applies two insertions at the same point', async () => {
103
+ fs.writeFileSync(path.join(dir, 'b.json'), '{}');
104
+
105
+ const changes = await applyFixes(site(), [
106
+ diagnostic({ file: 'b.json', start: 1, end: 1, text: ',"j":2' }),
107
+ diagnostic({ file: 'b.json', start: 1, end: 1, text: '"k":1' }),
108
+ ]);
109
+
110
+ expect(fs.readFileSync(path.join(dir, 'b.json'), 'utf-8')).toBe('{"k":1,"j":2}');
111
+ expect(changes).toHaveLength(2);
112
+ });
113
+
114
+ it('applies a replacement and an insertion at its start', async () => {
115
+ fs.writeFileSync(path.join(dir, 'a.mdx'), 'a<br>b');
116
+
117
+ const changes = await applyFixes(site(), [
118
+ diagnostic({ file: 'a.mdx', start: 1, end: 5, text: '<br />' }),
119
+ diagnostic({ file: 'a.mdx', start: 1, end: 1, text: 'x' }),
120
+ ]);
121
+
122
+ expect(fs.readFileSync(path.join(dir, 'a.mdx'), 'utf-8')).toBe('ax<br />b');
123
+ expect(changes.map(({ after }) => after)).toEqual(['<br />', 'x']);
124
+ });
125
+
126
+ it('skips writing a Markdown file whose fixed content no longer parses', async () => {
127
+ fs.writeFileSync(path.join(dir, 'a.mdx'), 'hello world\n');
128
+ fs.writeFileSync(path.join(dir, 'b.mdx'), 'line<br>\n');
129
+
130
+ const changes = await applyFixes(site(), [
131
+ diagnostic({ file: 'a.mdx', start: 0, end: 5, text: '<Note>' }),
132
+ diagnostic({ file: 'b.mdx', start: 4, end: 8, text: '<br />' }),
133
+ ]);
134
+
135
+ expect(fs.readFileSync(path.join(dir, 'a.mdx'), 'utf-8')).toBe('hello world\n');
136
+ expect(fs.readFileSync(path.join(dir, 'b.mdx'), 'utf-8')).toBe('line<br />\n');
137
+ expect(changes.map(({ file, after }) => [file, after])).toEqual([['b.mdx', '<br />']]);
138
+ });
139
+
140
+ it('fixes a page that only parses after the build preprocesses its <MDX> fragments', async () => {
141
+ const fragment = '{true ? <MDX>\n```bash\n<root>/{arch}/app.vpkg\n```\n</MDX> : null}\n';
142
+ fs.writeFileSync(path.join(dir, 'a.mdx'), `${fragment}\nline<br>\n`);
143
+ const start = fragment.length + 5;
144
+
145
+ const changes = await applyFixes(site(), [
146
+ diagnostic({ file: 'a.mdx', start, end: start + 4, text: '<br />' }),
147
+ ]);
148
+
149
+ expect(fs.readFileSync(path.join(dir, 'a.mdx'), 'utf-8')).toBe(`${fragment}\nline<br />\n`);
150
+ expect(changes).toHaveLength(1);
151
+ });
152
+
153
+ it('fixes an unparseable page even when another error keeps it unparseable', async () => {
154
+ fs.writeFileSync(path.join(dir, 'a.mdx'), 'line<br>\n');
155
+ fs.writeFileSync(path.join(dir, 'b.mdx'), 'line<br>\n\nText {broken\n');
156
+
157
+ const changes = await applyFixes(site(), [
158
+ diagnostic({ file: 'a.mdx', start: 4, end: 8, text: '<br />' }),
159
+ diagnostic({ file: 'b.mdx', start: 4, end: 8, text: '<br />' }),
160
+ ]);
161
+
162
+ expect(fs.readFileSync(path.join(dir, 'a.mdx'), 'utf-8')).toBe('line<br />\n');
163
+ expect(fs.readFileSync(path.join(dir, 'b.mdx'), 'utf-8')).toBe('line<br />\n\nText {broken\n');
164
+ expect(changes).toHaveLength(2);
165
+ });
166
+ });
167
+
168
+ describe('checkSite with fix', () => {
169
+ let dir: string;
170
+
171
+ beforeEach(() => {
172
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-fix-passes-'));
173
+ fs.writeJSONSync(path.join(dir, 'docs.json'), {
174
+ name: 'Test',
175
+ navigation: { pages: ['index', 'other'] },
176
+ });
177
+ fs.writeFileSync(path.join(dir, 'index.mdx'), '---\ntitle: Home\n---\n\nText\n');
178
+ fs.writeFileSync(path.join(dir, 'other.mdx'), '---\ntitle: Other\n---\n\nText!!!\n');
179
+ });
180
+
181
+ afterEach(() => {
182
+ fs.removeSync(dir);
183
+ });
184
+
185
+ /** Offers one "!" at a time until a page ends with `limit` of them, so each pass unlocks the next. */
186
+ const bang = (limit: number): FileRule => ({
187
+ id: 'bang',
188
+ group: 'content',
189
+ severity: 'warning',
190
+ scope: 'file',
191
+ raw: true,
192
+ description: 'test rule',
193
+ check: ({ content }) => {
194
+ const end = content.trimEnd().length;
195
+ return content.endsWith(`${'!'.repeat(limit)}\n`)
196
+ ? []
197
+ : [{ message: 'needs a bang', edit: { start: end, end, text: '!' } }];
198
+ },
199
+ });
200
+
201
+ const siteRule = (): SiteRule => ({
202
+ id: 'site',
203
+ group: 'config',
204
+ severity: 'warning',
205
+ scope: 'site',
206
+ description: 'test rule',
207
+ check: vi.fn(() => []),
208
+ });
209
+
210
+ it('rechecks only changed files between passes and runs site rules once more at the end', async () => {
211
+ const site = siteRule();
212
+ const reads = vi.spyOn(fsp, 'readFile');
213
+
214
+ const result = await checkSite(dir, [bang(3), site], { localSchema: false, fix: true });
215
+
216
+ const otherReads = reads.mock.calls.filter(([file]) => String(file).endsWith('other.mdx'));
217
+ reads.mockRestore();
218
+ expect(result.fixed.map(({ file }) => file)).toEqual(['index.mdx', 'index.mdx', 'index.mdx']);
219
+ expect(fs.readFileSync(path.join(dir, 'index.mdx'), 'utf-8')).toContain('Text!!!\n');
220
+ expect(result.diagnostics).toEqual([]);
221
+ expect(site.check).toHaveBeenCalledTimes(2);
222
+ expect(otherReads).toHaveLength(2);
223
+ });
224
+
225
+ it('applies up to five passes of fixes', async () => {
226
+ const result = await checkSite(dir, [bang(10)], { localSchema: false, fix: true });
227
+
228
+ expect(result.fixed.filter(({ file }) => file === 'index.mdx')).toHaveLength(5);
229
+ });
230
+ });
@@ -20,11 +20,15 @@ const hasErrors = (diagnostics: Diagnostic[]) =>
20
20
  diagnostics.some(({ severity }) => severity === 'error');
21
21
 
22
22
  /** Runs the file rules (all defaults, or exactly `rules`) the way mint validate --no-build does. */
23
- const check = async (dir: string, { rules, files }: { rules?: string[]; files?: string[] }) => {
23
+ const check = async (
24
+ dir: string,
25
+ { rules, fix = false, files }: { rules?: string[]; fix?: boolean; files?: string[] }
26
+ ) => {
24
27
  const selected = selectRules({ disable: ['all'], enable: rules ?? ['content'] });
25
28
  const result = await checkSite(dir, selected, {
26
29
  selected: files && new Set(files),
27
30
  localSchema: false,
31
+ fix,
28
32
  });
29
33
  const issues = result.diagnostics.map((diagnostic) => ({
30
34
  ...diagnostic,
@@ -32,6 +36,8 @@ const check = async (dir: string, { rules, files }: { rules?: string[]; files?:
32
36
  }));
33
37
  return {
34
38
  issues,
39
+ fixed: result.fixed,
40
+ filesFixed: [...new Set(result.fixed.map(({ file }) => file))].sort(),
35
41
  filesChecked: result.site.files.filter(({ file }) => !files || files.includes(file)).length,
36
42
  };
37
43
  };
@@ -59,26 +65,23 @@ describe('validate rules', () => {
59
65
 
60
66
  const read = (file: string) => fs.readFileSync(path.join(dir, file), 'utf-8');
61
67
 
62
- /** The edit each diagnostic carries, as the text it would replace and its replacement. */
63
- const previews = (issues: Diagnostic[]) =>
64
- issues.map(
65
- ({ file, fix }) => fix && { before: read(file).slice(fix.start, fix.end), after: fix.text }
66
- );
67
-
68
- it('flags lowercase void tags with edits, skipping code and components', async () => {
68
+ it('self-closes lowercase void tags, skipping code and components', async () => {
69
69
  write(dir, {
70
70
  'index.mdx':
71
71
  '---\ntitle: Home\n---\n\nline<br>\n\n<hr>\n\n<img src="/a.png" alt="a">\n\n<Link>x</Link>\n\n`<br>`\n\n```html\n<br>\n```\n',
72
72
  });
73
73
 
74
- const result = await check(dir, {});
74
+ const plain = await check(dir, {});
75
+ expect(plain.issues.map(({ rule }) => rule)).toEqual(['void-tags', 'void-tags', 'void-tags']);
75
76
 
76
- expect(result.issues.map(({ rule }) => rule)).toEqual(['void-tags', 'void-tags', 'void-tags']);
77
- expect(previews(result.issues)).toEqual([
78
- { before: '<br>', after: '<br />' },
79
- { before: '<hr>', after: '<hr />' },
80
- { before: '<img src="/a.png" alt="a">', after: '<img src="/a.png" alt="a" />' },
81
- ]);
77
+ const fixed = await check(dir, { fix: true });
78
+ expect(fixed.issues).toEqual([]);
79
+ const content = read('index.mdx');
80
+ expect(content).toContain('line<br />');
81
+ expect(content).toContain('<hr />');
82
+ expect(content).toContain('<img src="/a.png" alt="a" />');
83
+ expect(content).toContain('`<br>`');
84
+ expect(content).toContain('```html\n<br>\n```');
82
85
  });
83
86
 
84
87
  it('skips void tags inside block-quoted code fences', async () => {
@@ -87,16 +90,13 @@ describe('validate rules', () => {
87
90
  'other.mdx': '---\ntitle: Other\n---\n\n> ```\n> <br>\n\nafter<br>\n',
88
91
  });
89
92
 
90
- const result = await check(dir, {});
93
+ const result = await check(dir, { fix: true });
91
94
 
92
- expect(result.issues.map(({ file, line }) => [file, line])).toEqual([
93
- ['index.mdx', 9],
94
- ['other.mdx', 8],
95
- ]);
96
- expect(previews(result.issues)).toEqual([
97
- { before: '<br>', after: '<br />' },
98
- { before: '<br>', after: '<br />' },
99
- ]);
95
+ expect(result.fixed.map(({ before }) => before).sort()).toEqual(['<br>', '<br>']);
96
+ expect(read('index.mdx')).toContain('> <br>\n');
97
+ expect(read('index.mdx')).toContain('line<br />');
98
+ expect(read('other.mdx')).toContain('> <br>\n');
99
+ expect(read('other.mdx')).toContain('after<br />');
100
100
  });
101
101
 
102
102
  it('keeps an info-string fence line from closing an open fence', async () => {
@@ -105,10 +105,12 @@ describe('validate rules', () => {
105
105
  '---\ntitle: Home\n---\n\n```bash\n$ one\n\nStep 2\n\n```bash\n$ two <br>\n```\n\nafter<br>\n',
106
106
  });
107
107
 
108
- const result = await check(dir, {});
108
+ const result = await check(dir, { fix: true });
109
109
 
110
- expect(result.issues.map(({ line }) => line)).toEqual([14]);
111
- expect(previews(result.issues)).toEqual([{ before: '<br>', after: '<br />' }]);
110
+ expect(result.fixed.map(({ before }) => before)).toEqual(['<br>']);
111
+ const content = read('index.mdx');
112
+ expect(content).toContain('$ two <br>\n');
113
+ expect(content).toContain('after<br />');
112
114
  });
113
115
 
114
116
  it('does not close a block-quoted fence with an info-string fence line', async () => {
@@ -117,10 +119,12 @@ describe('validate rules', () => {
117
119
  '---\ntitle: Home\n---\n\n> ```\n> <br>\n> ```bash\n> <hr>\n> ```\n\nafter<br>\n',
118
120
  });
119
121
 
120
- const result = await check(dir, {});
122
+ const result = await check(dir, { fix: true });
121
123
 
122
- expect(result.issues.map(({ line }) => line)).toEqual([11]);
123
- expect(previews(result.issues)).toEqual([{ before: '<br>', after: '<br />' }]);
124
+ expect(result.fixed.map(({ before }) => before)).toEqual(['<br>']);
125
+ const content = read('index.mdx');
126
+ expect(content).toContain('> <hr>\n');
127
+ expect(content).toContain('after<br />');
124
128
  });
125
129
 
126
130
  it('skips custom elements, frontmatter, ESM and attribute strings for void tags', async () => {
@@ -130,11 +134,15 @@ describe('validate rules', () => {
130
134
  });
131
135
 
132
136
  const { issues } = await check(dir, { rules: ['void-tags'] });
133
-
134
137
  expect(issues.map(({ line }) => line)).toEqual([12]);
135
- expect(previews(issues)).toEqual([
136
- { before: '<img src="/a.png" alt="<hr>">', after: '<img src="/a.png" alt="<hr>" />' },
137
- ]);
138
+
139
+ await check(dir, { rules: ['void-tags'], fix: true });
140
+ const content = read('index.mdx');
141
+ expect(content).toContain('<img src="/a.png" alt="<hr>" />');
142
+ expect(content).toContain('title: "a<br>b"');
143
+ expect(content).toContain('export const html = "<br>";');
144
+ expect(content).toContain('<Card title="one<br>two">');
145
+ expect(content).toContain('`` a`<br>`b ``');
138
146
  });
139
147
 
140
148
  it('leaves escaped void tags alone', async () => {
@@ -159,7 +167,9 @@ describe('validate rules', () => {
159
167
  expect(issues.map(({ rule, line, column }) => [rule, line, column])).toEqual([
160
168
  ['void-tags', 1, 23],
161
169
  ]);
162
- expect(previews(issues)).toEqual([{ before: '<br>', after: '<br />' }]);
170
+
171
+ await check(dir, { fix: true });
172
+ expect(read('snippets/note.mdx')).toBe('Hello {{foo.bar}} line<br />\n');
163
173
  });
164
174
 
165
175
  it('leaves void tags that already have a closing tag', async () => {
@@ -167,6 +177,8 @@ describe('validate rules', () => {
167
177
  write(dir, { 'index.mdx': page });
168
178
 
169
179
  expect((await check(dir, { rules: ['void-tags'] })).issues).toEqual([]);
180
+ await check(dir, { rules: ['void-tags'], fix: true });
181
+ expect(read('index.mdx')).toBe(page);
170
182
  });
171
183
 
172
184
  it('accepts <MDX> fragments the build preprocesses', async () => {
@@ -222,15 +234,17 @@ describe('validate rules', () => {
222
234
  });
223
235
 
224
236
  const { issues } = await check(dir, { rules: ['void-tags'] });
225
-
226
237
  expect(issues.map(({ rule, line }) => [rule, line])).toEqual([
227
238
  ['void-tags', 7],
228
239
  ['void-tags', 11],
229
240
  ]);
230
- expect(previews(issues)).toEqual([
231
- { before: '<br>', after: '<br />' },
232
- { before: '<br>', after: '<br />' },
233
- ]);
241
+
242
+ await check(dir, { rules: ['void-tags'], fix: true });
243
+ const content = read('index.mdx');
244
+ expect(content).toContain("s.replace(/<br>/g, '')");
245
+ expect(content).toContain("replace(/\\/<hr>/, '')");
246
+ expect(content).toContain('and<br />');
247
+ expect(content).toContain('line<br />');
234
248
  });
235
249
 
236
250
  it('masks line comments inside expressions', async () => {
@@ -262,11 +276,11 @@ describe('validate rules', () => {
262
276
  );
263
277
  write(dir, Object.fromEntries(names.map((name) => [name, '---\ntitle: T\n---\n\nline<br>\n'])));
264
278
 
265
- const result = await check(dir, { rules: ['void-tags'] });
279
+ const plain = await check(dir, { rules: ['void-tags'] });
280
+ expect(plain.issues.map(({ file }) => file)).toEqual(names);
266
281
 
267
- const flagged = result.issues.map(({ file }) => file);
268
- expect(flagged).toEqual([...flagged].sort());
269
- expect(flagged).toHaveLength(40);
282
+ const fixed = await check(dir, { rules: ['void-tags'], fix: true });
283
+ expect(fixed.fixed.map(({ file }) => file)).toEqual(names);
270
284
  });
271
285
 
272
286
  it('rejects unknown rule ids', async () => {
@@ -368,8 +382,9 @@ describe('validate rules', () => {
368
382
 
369
383
  const { issues } = await check(dir, { rules: ['void-tags'] });
370
384
 
371
- expect(issues.map(({ line }) => line)).toEqual([7]);
372
- expect(previews(issues)).toEqual([{ before: '<br>', after: '<br />' }]);
385
+ expect(issues.map(({ line, message }) => [line, message])).toEqual([
386
+ [7, '<br> must be self-closed'],
387
+ ]);
373
388
  });
374
389
 
375
390
  it('does not pair backticks across table cells', async () => {
@@ -403,9 +418,9 @@ describe('validate rules', () => {
403
418
 
404
419
  const { issues } = await check(dir, { rules: ['void-tags'] });
405
420
 
406
- expect(previews(issues)).toEqual([
407
- { before: '<br>', after: '<br />' },
408
- { before: '<hr>', after: '<hr />' },
421
+ expect(issues.map(({ message }) => message)).toEqual([
422
+ '<br> must be self-closed',
423
+ '<hr> must be self-closed',
409
424
  ]);
410
425
  });
411
426
  });
package/bin/cli.js CHANGED
@@ -328,6 +328,11 @@ export const cli = ({ packageName = 'mint' }) => {
328
328
  array: true,
329
329
  coerce: splitList,
330
330
  description: 'Turn off rules or groups (comma-separated), or all; parse errors are still reported',
331
+ })
332
+ .option('fix', {
333
+ type: 'boolean',
334
+ default: false,
335
+ description: 'Apply the fixes and check again',
331
336
  })
332
337
  .usage('usage: mint validate [options]')
333
338
  .epilogue([
@@ -336,7 +341,7 @@ export const cli = ({ packageName = 'mint' }) => {
336
341
  ` parse-error — ${PARSE_ERROR.description} (always runs)`,
337
342
  ].join('\n'))
338
343
  .example('mint validate', 'run every check and the build')
339
- .example('mint validate --files "guides/**/*.mdx"', 'check some pages')
344
+ .example('mint validate --files "guides/**/*.mdx" --fix', 'check some pages and apply fixes')
340
345
  .example('mint validate --disable all --enable content', 'run the content rules only; missing-file and import-error still run the build')
341
346
  .strict(), (argv) => validateHandler(argv, packageName))
342
347
  .command('export', 'Export a static site for air-gapped deployment', (yargs) => yargs