@mintlify/cli 4.0.1740 → 4.0.1742

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.
@@ -21,6 +21,7 @@ var __rest = (this && this.__rest) || function (s, e) {
21
21
  import { jsx as _jsx } from "react/jsx-runtime";
22
22
  import { addLog, checkBuild, clearLogs, ErrorLog, SpinnerLog } from '@mintlify/previewing';
23
23
  import path from 'node:path';
24
+ import { format } from 'node:util';
24
25
  import { CMD_EXEC_PATH, errorMessage, getVersions, terminate } from '../helpers.js';
25
26
  import { resolveExplicitFiles } from '../resolveFiles.js';
26
27
  import { sendPublishPrompt } from '../signup.js';
@@ -32,6 +33,26 @@ export const splitList = (values) => values.flatMap((value) => value
32
33
  .split(',')
33
34
  .map((item) => item.trim())
34
35
  .filter(Boolean));
36
+ const write = (text) => new Promise((resolve) => {
37
+ process.stdout.write(text, () => resolve());
38
+ });
39
+ /**
40
+ * Writes console output straight to stderr, so --format json leaves stdout to the report. Ink
41
+ * patches console and redraws on stdout, so redirecting to console.error isn't enough.
42
+ */
43
+ const consoleToStderr = () => {
44
+ const original = {
45
+ log: console.log,
46
+ info: console.info,
47
+ warn: console.warn,
48
+ error: console.error,
49
+ };
50
+ const toStderr = (...args) => {
51
+ process.stderr.write(`${format(...args)}\n`);
52
+ };
53
+ console.log = console.info = console.warn = console.error = toStderr;
54
+ return () => Object.assign(console, original);
55
+ };
35
56
  const resolveSelection = (baseDir, values) => __awaiter(void 0, void 0, void 0, function* () {
36
57
  const args = (values !== null && values !== void 0 ? values : []).map(String).filter(Boolean);
37
58
  if (args.length === 0)
@@ -42,17 +63,21 @@ const resolveSelection = (baseDir, values) => __awaiter(void 0, void 0, void 0,
42
63
  /** mint validate: one pass over every selected rule, then the strict build when nothing failed. */
43
64
  export const validateHandler = (argv_1, packageName_1, ...args_1) => __awaiter(void 0, [argv_1, packageName_1, ...args_1], void 0, function* (argv, packageName, baseDir = CMD_EXEC_PATH) {
44
65
  var _a;
66
+ const json = argv.format === 'json';
67
+ const restoreConsole = json ? consoleToStderr() : undefined;
45
68
  try {
46
69
  const rules = selectRules({
47
70
  enable: argv.enable,
48
71
  disable: (_a = argv.disable) !== null && _a !== void 0 ? _a : [],
49
72
  });
50
73
  const selected = yield resolveSelection(baseDir, argv.files);
51
- addLog(_jsx(SpinnerLog, { message: "validating..." }));
74
+ if (!json)
75
+ addLog(_jsx(SpinnerLog, { message: "validating..." }));
52
76
  const _b = yield checkSite(baseDir, rules, {
53
77
  selected,
54
78
  localSchema: argv['local-schema'],
55
- }), { site } = _b, checked = __rest(_b, ["site"]);
79
+ fix: argv.fix,
80
+ }), { site, fixed } = _b, checked = __rest(_b, ["site", "fixed"]);
56
81
  let { diagnostics } = checked;
57
82
  const buildRules = rules.some(({ needsBuild }) => needsBuild);
58
83
  let build;
@@ -66,24 +91,37 @@ export const validateHandler = (argv_1, packageName_1, ...args_1) => __awaiter(v
66
91
  build = { ran: false, reason: 'errors found' };
67
92
  }
68
93
  else {
69
- addLog(_jsx(SpinnerLog, { message: "building..." }));
94
+ if (!json)
95
+ addLog(_jsx(SpinnerLog, { message: "building..." }));
70
96
  const { cli: cliVersion } = getVersions(packageName);
71
97
  site.build = yield checkBuild(Object.assign(Object.assign({}, argv), { _: [], $0: packageName, packageName,
72
98
  cliVersion, disableOpenapi: !rules.some(({ group }) => group === 'openapi') }));
73
99
  diagnostics = yield runBuildChecks(site, rules, diagnostics);
74
100
  build = { ran: true, passed: site.build.passed };
75
101
  }
76
- const report = toReport(diagnostics, build);
77
- clearLogs();
78
- logReport(report);
79
- if (report.passed && build.ran && build.passed)
80
- yield sendPublishPrompt();
102
+ const report = toReport(diagnostics, build, argv.fix ? fixed : undefined);
103
+ if (json) {
104
+ restoreConsole === null || restoreConsole === void 0 ? void 0 : restoreConsole();
105
+ yield write(`${JSON.stringify(report)}\n`);
106
+ }
107
+ else {
108
+ clearLogs();
109
+ logReport(report);
110
+ if (report.passed && build.ran && build.passed)
111
+ yield sendPublishPrompt();
112
+ }
81
113
  yield terminate(report.passed ? EXIT.passed : EXIT.failed);
82
114
  }
83
115
  catch (error) {
84
116
  // Bad arguments and crashes alike mean validate could not run
85
- clearLogs();
86
- addLog(_jsx(ErrorLog, { message: errorMessage(error) }));
117
+ if (json) {
118
+ restoreConsole === null || restoreConsole === void 0 ? void 0 : restoreConsole();
119
+ process.stderr.write(`Error: ${errorMessage(error)}\n`);
120
+ }
121
+ else {
122
+ clearLogs();
123
+ addLog(_jsx(ErrorLog, { message: errorMessage(error) }));
124
+ }
87
125
  yield terminate(EXIT.crashed);
88
126
  }
89
127
  });
@@ -7,7 +7,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { checkFiles } from './files.js';
10
+ import { replaceVariables } from '@mintlify/common';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { checkFiles, parseFile } from './files.js';
14
+ import { applyEdits, selectEdits, toPosition } from './mdx.js';
11
15
  import { buildFindings } from './rules/build.js';
12
16
  import { RULES } from './rules/index.js';
13
17
  import { PARSE_ERROR } from './rules/parseError.js';
@@ -64,37 +68,94 @@ const merge = (diagnostics, source, extra, key) => {
64
68
  }
65
69
  };
66
70
  const addParseErrors = (site, diagnostics, extra) => merge(diagnostics, PARSE_ERROR, extra.filter(({ file }) => !site.selected || site.selected.has(file)), ({ file }) => file);
67
- /** Runs every selected rule that doesn't need the build. */
68
- export const runChecks = (site, rules) => __awaiter(void 0, void 0, void 0, function* () {
69
- const fileRules = rules.filter((rule) => rule.scope === 'file');
70
- const diagnostics = yield checkFiles(site, fileRules);
71
+ const fileRulesOf = (rules) => rules.filter((rule) => rule.scope === 'file');
72
+ const runSiteRules = (site, rules, needsBuild, diagnostics) => __awaiter(void 0, void 0, void 0, function* () {
71
73
  for (const rule of rules) {
72
- if (rule.scope !== 'site' || rule.needsBuild)
74
+ if (rule.scope !== 'site' || Boolean(rule.needsBuild) !== needsBuild)
73
75
  continue;
74
76
  for (const finding of yield rule.check(site))
75
77
  diagnostics.push(toDiagnostic(rule, finding));
76
78
  }
79
+ });
80
+ /** Runs every selected rule that doesn't need the build. */
81
+ export const runChecks = (site, rules) => __awaiter(void 0, void 0, void 0, function* () {
82
+ const diagnostics = yield checkFiles(site, fileRulesOf(rules));
83
+ yield runSiteRules(site, rules, false, diagnostics);
77
84
  return sortDiagnostics(diagnostics);
78
85
  });
79
86
  /** Adds the build's findings to a site whose build has run. */
80
87
  export const runBuildChecks = (site, rules, diagnostics) => __awaiter(void 0, void 0, void 0, function* () {
81
- for (const rule of rules) {
82
- if (rule.scope !== 'site' || !rule.needsBuild)
83
- continue;
84
- for (const finding of yield rule.check(site))
85
- diagnostics.push(toDiagnostic(rule, finding));
86
- }
88
+ yield runSiteRules(site, rules, true, diagnostics);
87
89
  // The file pass already reported files it couldn't parse; the build adds the rest
88
90
  addParseErrors(site, diagnostics, buildFindings(site, 'parse-error'));
89
91
  return sortDiagnostics(diagnostics);
90
92
  });
91
- /** Loads the site and runs the selected rules. */
93
+ const parseLevel = (file, content, baseDir) => {
94
+ const parsed = parseFile(file, content, baseDir);
95
+ return 'error' in parsed ? 0 : parsed.approximate ? 1 : 2;
96
+ };
97
+ /** Applies every fix that doesn't overlap an earlier fix in the same file, writing each Markdown file only when its fixed content parses at least as well as before. */
98
+ export const applyFixes = (site, diagnostics) => __awaiter(void 0, void 0, void 0, function* () {
99
+ var _a;
100
+ const contents = new Map();
101
+ const read = (file) => __awaiter(void 0, void 0, void 0, function* () {
102
+ var _a;
103
+ if (!contents.has(file)) {
104
+ contents.set(file, yield fs.readFile(path.join(site.baseDir, file), 'utf-8'));
105
+ }
106
+ return (_a = contents.get(file)) !== null && _a !== void 0 ? _a : '';
107
+ });
108
+ const chosen = new Map();
109
+ const overlaps = ({ file, start, end }) => { var _a; return ((_a = chosen.get(file)) !== null && _a !== void 0 ? _a : []).some(({ fix }) => start < fix.end && fix.start < end); };
110
+ for (const { rule, fix } of diagnostics) {
111
+ if (!fix || overlaps(fix))
112
+ continue;
113
+ const content = yield read(fix.file);
114
+ const change = Object.assign(Object.assign({ rule, file: fix.file }, toPosition(content, fix.start)), { before: content.slice(fix.start, fix.end), after: fix.text });
115
+ chosen.set(fix.file, [...((_a = chosen.get(fix.file)) !== null && _a !== void 0 ? _a : []), { fix, change }]);
116
+ }
117
+ const snippets = new Set(site.files.filter(({ kind }) => kind === 'snippet').map(({ file }) => file));
118
+ const level = (file, content) => parseLevel(file, snippets.has(file) ? replaceVariables(content, site.variables) : content, site.baseDir);
119
+ const applied = [];
120
+ for (const [file, picks] of chosen) {
121
+ const content = yield read(file);
122
+ const updated = applyEdits(content, selectEdits(picks.map(({ fix }) => fix)));
123
+ if (/\.mdx?$/.test(file) && level(file, updated) < level(file, content)) {
124
+ continue;
125
+ }
126
+ yield fs.writeFile(path.join(site.baseDir, file), updated);
127
+ applied.push(...picks.map(({ change }) => change));
128
+ }
129
+ return applied;
130
+ });
131
+ // Fixes can unlock others (a self-closed <br> lets a page parse), so --fix runs a few passes
132
+ const MAX_FIX_PASSES = 5;
133
+ /**
134
+ * Loads the site and runs the selected rules. With fix, applies the fixes, rechecks only the
135
+ * files that changed for further fixes, then runs every rule once more for the final report.
136
+ */
92
137
  export const checkSite = (baseDir, rules, options) => __awaiter(void 0, void 0, void 0, function* () {
138
+ const fixed = [];
93
139
  const site = yield loadSite(baseDir, options);
94
140
  const { selected } = options;
95
141
  if (selected && !site.files.some(({ file }) => selected.has(file))) {
96
142
  throw new UsageError(`none of the given files are pages or snippets of this site: ${[...selected].join(', ')}`);
97
143
  }
98
144
  const diagnostics = yield runChecks(site, rules);
99
- return { site, diagnostics };
145
+ if (!options.fix)
146
+ return { site, diagnostics, fixed };
147
+ // Only file rules offer fixes, so passes after the first recheck just the files that changed
148
+ let pending = diagnostics;
149
+ for (let pass = 0; pass < MAX_FIX_PASSES; pass++) {
150
+ const changes = yield applyFixes(site, pending);
151
+ if (changes.length === 0)
152
+ break;
153
+ fixed.push(...changes);
154
+ const changed = new Set(changes.map(({ file }) => file));
155
+ pending = sortDiagnostics(yield checkFiles(Object.assign(Object.assign({}, site), { selected: changed }), fileRulesOf(rules)));
156
+ }
157
+ if (fixed.length === 0)
158
+ return { site, diagnostics, fixed };
159
+ const fixedSite = yield loadSite(baseDir, options);
160
+ return { site: fixedSite, diagnostics: yield runChecks(fixedSite, rules), fixed };
100
161
  });
@@ -74,7 +74,7 @@ const tryParseContent = (content) => {
74
74
  return undefined;
75
75
  }
76
76
  };
77
- const parseFile = (file, content, baseDir) => {
77
+ export const parseFile = (file, content, baseDir) => {
78
78
  const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
79
79
  const tree = tryParseContent(content);
80
80
  if (tree)
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { addLog, ErrorLog, InfoLog, SuccessLog } from '@mintlify/previewing';
3
3
  import { Box, Text } from 'ink';
4
+ const preview = (value) => {
5
+ const text = JSON.stringify(value);
6
+ return text.length > 60 ? `${text.slice(0, 57)}..."` : text;
7
+ };
4
8
  const location = ({ line, column }) => line ? `${line}${column ? `:${column}` : ''}` : '';
5
9
  const DiagnosticLine = ({ diagnostic }) => {
6
10
  const at = location(diagnostic);
@@ -8,14 +12,21 @@ const DiagnosticLine = ({ diagnostic }) => {
8
12
  };
9
13
  const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
10
14
  export const logReport = (report) => {
11
- var _a, _b;
15
+ var _a, _b, _c;
16
+ for (const change of (_a = report.fixed) !== null && _a !== void 0 ? _a : []) {
17
+ addLog(_jsx(SuccessLog, { message: `fixed ${change.file}${change.line ? `:${change.line}` : ''} ${change.rule}: ${preview(change.before)} → ${preview(change.after)}` }));
18
+ }
12
19
  const byFile = new Map();
13
20
  for (const diagnostic of report.diagnostics) {
14
- (_b = (_a = byFile.get(diagnostic.file)) === null || _a === void 0 ? void 0 : _a.push(diagnostic)) !== null && _b !== void 0 ? _b : byFile.set(diagnostic.file, [diagnostic]);
21
+ (_c = (_b = byFile.get(diagnostic.file)) === null || _b === void 0 ? void 0 : _b.push(diagnostic)) !== null && _c !== void 0 ? _c : byFile.set(diagnostic.file, [diagnostic]);
15
22
  }
16
23
  for (const [file, diagnostics] of byFile) {
17
24
  addLog(_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: file }), diagnostics.map((diagnostic, index) => (_jsx(DiagnosticLine, { diagnostic: diagnostic }, index)))] }));
18
25
  }
26
+ const fixable = report.diagnostics.filter(({ fix }) => fix).length;
27
+ if (fixable > 0 && !report.fixed) {
28
+ addLog(_jsx(InfoLog, { message: `\`--fix\` would fix ${plural(fixable, 'issue')}.` }));
29
+ }
19
30
  const { build } = report;
20
31
  if (build.ran && build.passed)
21
32
  addLog(_jsx(SuccessLog, { message: "build passed" }));
@@ -1,16 +1,11 @@
1
- export const toReport = (diagnostics, build) => {
1
+ export const toReport = (diagnostics, build, fixed) => {
2
2
  var _a;
3
3
  const byRule = {};
4
4
  for (const { rule } of diagnostics)
5
5
  byRule[rule] = ((_a = byRule[rule]) !== null && _a !== void 0 ? _a : 0) + 1;
6
6
  const errors = diagnostics.filter(({ severity }) => severity === 'error').length;
7
- return {
8
- passed: errors === 0,
9
- summary: { errors, warnings: diagnostics.length - errors, byRule },
10
- diagnostics: diagnostics.map(({ rule, group, severity, file, line, column, message, fix }) => (Object.assign(Object.assign(Object.assign(Object.assign({ rule,
7
+ return Object.assign({ passed: errors === 0, summary: { errors, warnings: diagnostics.length - errors, byRule }, diagnostics: diagnostics.map(({ rule, group, severity, file, line, column, message, fix }) => (Object.assign(Object.assign(Object.assign(Object.assign({ rule,
11
8
  group,
12
9
  severity,
13
- file }, (line !== undefined && { line })), (column !== undefined && { column })), { message }), (fix && { fix })))),
14
- build,
15
- };
10
+ file }, (line !== undefined && { line })), (column !== undefined && { column })), { message }), (fix && { fix })))), build }, (fixed && { fixed }));
16
11
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/cli",
3
- "version": "4.0.1740",
3
+ "version": "4.0.1742",
4
4
  "description": "The Mintlify CLI",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -46,11 +46,11 @@
46
46
  "dependencies": {
47
47
  "@inquirer/prompts": "7.9.0",
48
48
  "@mintlify/common": "1.0.1307",
49
- "@mintlify/link-rot": "3.0.1537",
49
+ "@mintlify/link-rot": "3.0.1538",
50
50
  "@mintlify/models": "0.0.379",
51
51
  "@mintlify/og": "0.0.106",
52
52
  "@mintlify/prebuild": "1.0.1474",
53
- "@mintlify/previewing": "4.0.1555",
53
+ "@mintlify/previewing": "4.0.1556",
54
54
  "@mintlify/validation": "0.1.944",
55
55
  "adm-zip": "0.6.0",
56
56
  "chalk": "5.2.0",
@@ -101,5 +101,5 @@
101
101
  "vitest": "2.1.9",
102
102
  "vitest-mock-process": "1.0.4"
103
103
  },
104
- "gitHead": "17769e5210e7861c4b9c751f8b0cbf5e7bdd3143"
104
+ "gitHead": "d186b90eab824aece48724f71d3b71f2cdebdbe2"
105
105
  }
package/src/cli.tsx CHANGED
@@ -401,6 +401,18 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
401
401
  description:
402
402
  'Turn off rules or groups (comma-separated), or all; parse errors are still reported',
403
403
  })
404
+ .option('fix', {
405
+ type: 'boolean',
406
+ default: false,
407
+ description: 'Apply the fixes and check again',
408
+ })
409
+ .option('format', {
410
+ type: 'string',
411
+ choices: ['text', 'json'] as const,
412
+ default: 'text' as const,
413
+ description:
414
+ 'Output format; json prints one report ({ passed, summary, diagnostics, build }) and nothing else on stdout, and errors to stderr',
415
+ })
404
416
  .usage('usage: mint validate [options]')
405
417
  .epilogue(
406
418
  [
@@ -410,7 +422,14 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
410
422
  ].join('\n')
411
423
  )
412
424
  .example('mint validate', 'run every check and the build')
413
- .example('mint validate --files "guides/**/*.mdx"', 'check some pages')
425
+ .example(
426
+ 'mint validate --no-build --format json',
427
+ 'fast offline check for scripts and agents'
428
+ )
429
+ .example(
430
+ 'mint validate --files "guides/**/*.mdx" --fix',
431
+ 'check some pages and apply fixes'
432
+ )
414
433
  .example(
415
434
  'mint validate --disable all --enable content',
416
435
  'run the content rules only; missing-file and import-error still run the build'
@@ -1,5 +1,6 @@
1
1
  import { addLog, checkBuild, clearLogs, ErrorLog, SpinnerLog } from '@mintlify/previewing';
2
2
  import path from 'node:path';
3
+ import { format } from 'node:util';
3
4
 
4
5
  import { CMD_EXEC_PATH, errorMessage, getVersions, terminate } from '../helpers.js';
5
6
  import { resolveExplicitFiles } from '../resolveFiles.js';
@@ -16,6 +17,8 @@ export type ValidateArgs = {
16
17
  build: boolean;
17
18
  enable?: string[];
18
19
  disable?: string[];
20
+ fix: boolean;
21
+ format: 'text' | 'json';
19
22
  };
20
23
 
21
24
  export const EXIT = { passed: 0, failed: 1, crashed: 2 } as const;
@@ -28,6 +31,29 @@ export const splitList = (values: string[]) =>
28
31
  .filter(Boolean)
29
32
  );
30
33
 
34
+ const write = (text: string) =>
35
+ new Promise<void>((resolve) => {
36
+ process.stdout.write(text, () => resolve());
37
+ });
38
+
39
+ /**
40
+ * Writes console output straight to stderr, so --format json leaves stdout to the report. Ink
41
+ * patches console and redraws on stdout, so redirecting to console.error isn't enough.
42
+ */
43
+ const consoleToStderr = () => {
44
+ const original = {
45
+ log: console.log,
46
+ info: console.info,
47
+ warn: console.warn,
48
+ error: console.error,
49
+ };
50
+ const toStderr = (...args: unknown[]) => {
51
+ process.stderr.write(`${format(...args)}\n`);
52
+ };
53
+ console.log = console.info = console.warn = console.error = toStderr;
54
+ return () => Object.assign(console, original);
55
+ };
56
+
31
57
  const resolveSelection = async (baseDir: string, values: (string | number)[] | undefined) => {
32
58
  const args = (values ?? []).map(String).filter(Boolean);
33
59
  if (args.length === 0) return undefined;
@@ -45,17 +71,20 @@ export const validateHandler = async (
45
71
  packageName: string,
46
72
  baseDir = CMD_EXEC_PATH
47
73
  ) => {
74
+ const json = argv.format === 'json';
75
+ const restoreConsole = json ? consoleToStderr() : undefined;
48
76
  try {
49
77
  const rules = selectRules({
50
78
  enable: argv.enable,
51
79
  disable: argv.disable ?? [],
52
80
  });
53
81
  const selected = await resolveSelection(baseDir, argv.files);
54
- addLog(<SpinnerLog message="validating..." />);
82
+ if (!json) addLog(<SpinnerLog message="validating..." />);
55
83
 
56
- const { site, ...checked } = await checkSite(baseDir, rules, {
84
+ const { site, fixed, ...checked } = await checkSite(baseDir, rules, {
57
85
  selected,
58
86
  localSchema: argv['local-schema'],
87
+ fix: argv.fix,
59
88
  });
60
89
  let { diagnostics } = checked;
61
90
 
@@ -68,7 +97,7 @@ export const validateHandler = async (
68
97
  } else if (diagnostics.some(({ severity }) => severity === 'error')) {
69
98
  build = { ran: false, reason: 'errors found' };
70
99
  } else {
71
- addLog(<SpinnerLog message="building..." />);
100
+ if (!json) addLog(<SpinnerLog message="building..." />);
72
101
  const { cli: cliVersion } = getVersions(packageName);
73
102
  site.build = await checkBuild({
74
103
  ...argv,
@@ -82,15 +111,25 @@ export const validateHandler = async (
82
111
  build = { ran: true, passed: site.build.passed };
83
112
  }
84
113
 
85
- const report = toReport(diagnostics, build);
86
- clearLogs();
87
- logReport(report);
88
- if (report.passed && build.ran && build.passed) await sendPublishPrompt();
114
+ const report = toReport(diagnostics, build, argv.fix ? fixed : undefined);
115
+ if (json) {
116
+ restoreConsole?.();
117
+ await write(`${JSON.stringify(report)}\n`);
118
+ } else {
119
+ clearLogs();
120
+ logReport(report);
121
+ if (report.passed && build.ran && build.passed) await sendPublishPrompt();
122
+ }
89
123
  await terminate(report.passed ? EXIT.passed : EXIT.failed);
90
124
  } catch (error) {
91
125
  // Bad arguments and crashes alike mean validate could not run
92
- clearLogs();
93
- addLog(<ErrorLog message={errorMessage(error)} />);
126
+ if (json) {
127
+ restoreConsole?.();
128
+ process.stderr.write(`Error: ${errorMessage(error)}\n`);
129
+ } else {
130
+ clearLogs();
131
+ addLog(<ErrorLog message={errorMessage(error)} />);
132
+ }
94
133
  await terminate(EXIT.crashed);
95
134
  }
96
135
  };
@@ -1,12 +1,19 @@
1
- import { checkFiles } from './files.js';
1
+ import { replaceVariables } from '@mintlify/common';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { checkFiles, parseFile } from './files.js';
6
+ import { applyEdits, selectEdits, toPosition } from './mdx.js';
2
7
  import { buildFindings } from './rules/build.js';
3
8
  import { RULES } from './rules/index.js';
4
9
  import { PARSE_ERROR } from './rules/parseError.js';
5
10
  import { loadSite } from './site.js';
6
11
  import {
7
12
  GROUPS,
13
+ type Change,
8
14
  type Diagnostic,
9
15
  type DiagnosticSource,
16
+ type FileEdit,
10
17
  type FileRule,
11
18
  type Finding,
12
19
  type Rule,
@@ -87,35 +94,103 @@ const addParseErrors = (site: Site, diagnostics: Diagnostic[], extra: Finding[])
87
94
  ({ file }) => file
88
95
  );
89
96
 
90
- /** Runs every selected rule that doesn't need the build. */
91
- export const runChecks = async (site: Site, rules: Rule[]) => {
92
- const fileRules = rules.filter((rule): rule is FileRule => rule.scope === 'file');
93
- const diagnostics = await checkFiles(site, fileRules);
97
+ const fileRulesOf = (rules: Rule[]) =>
98
+ rules.filter((rule): rule is FileRule => rule.scope === 'file');
94
99
 
100
+ const runSiteRules = async (
101
+ site: Site,
102
+ rules: Rule[],
103
+ needsBuild: boolean,
104
+ diagnostics: Diagnostic[]
105
+ ) => {
95
106
  for (const rule of rules) {
96
- if (rule.scope !== 'site' || rule.needsBuild) continue;
107
+ if (rule.scope !== 'site' || Boolean(rule.needsBuild) !== needsBuild) continue;
97
108
  for (const finding of await rule.check(site)) diagnostics.push(toDiagnostic(rule, finding));
98
109
  }
110
+ };
111
+
112
+ /** Runs every selected rule that doesn't need the build. */
113
+ export const runChecks = async (site: Site, rules: Rule[]) => {
114
+ const diagnostics = await checkFiles(site, fileRulesOf(rules));
115
+ await runSiteRules(site, rules, false, diagnostics);
99
116
  return sortDiagnostics(diagnostics);
100
117
  };
101
118
 
102
119
  /** Adds the build's findings to a site whose build has run. */
103
120
  export const runBuildChecks = async (site: Site, rules: Rule[], diagnostics: Diagnostic[]) => {
104
- for (const rule of rules) {
105
- if (rule.scope !== 'site' || !rule.needsBuild) continue;
106
- for (const finding of await rule.check(site)) diagnostics.push(toDiagnostic(rule, finding));
107
- }
121
+ await runSiteRules(site, rules, true, diagnostics);
108
122
  // The file pass already reported files it couldn't parse; the build adds the rest
109
123
  addParseErrors(site, diagnostics, buildFindings(site, 'parse-error'));
110
124
  return sortDiagnostics(diagnostics);
111
125
  };
112
126
 
113
- /** Loads the site and runs the selected rules. */
127
+ const parseLevel = (file: string, content: string, baseDir: string) => {
128
+ const parsed = parseFile(file, content, baseDir);
129
+ return 'error' in parsed ? 0 : parsed.approximate ? 1 : 2;
130
+ };
131
+
132
+ /** Applies every fix that doesn't overlap an earlier fix in the same file, writing each Markdown file only when its fixed content parses at least as well as before. */
133
+ export const applyFixes = async (site: Site, diagnostics: Diagnostic[]): Promise<Change[]> => {
134
+ const contents = new Map<string, string>();
135
+ const read = async (file: string) => {
136
+ if (!contents.has(file)) {
137
+ contents.set(file, await fs.readFile(path.join(site.baseDir, file), 'utf-8'));
138
+ }
139
+ return contents.get(file) ?? '';
140
+ };
141
+ const chosen = new Map<string, { fix: FileEdit; change: Change }[]>();
142
+ const overlaps = ({ file, start, end }: FileEdit) =>
143
+ (chosen.get(file) ?? []).some(({ fix }) => start < fix.end && fix.start < end);
144
+
145
+ for (const { rule, fix } of diagnostics) {
146
+ if (!fix || overlaps(fix)) continue;
147
+ const content = await read(fix.file);
148
+ const change = {
149
+ rule,
150
+ file: fix.file,
151
+ ...toPosition(content, fix.start),
152
+ before: content.slice(fix.start, fix.end),
153
+ after: fix.text,
154
+ };
155
+ chosen.set(fix.file, [...(chosen.get(fix.file) ?? []), { fix, change }]);
156
+ }
157
+
158
+ const snippets = new Set(
159
+ site.files.filter(({ kind }) => kind === 'snippet').map(({ file }) => file)
160
+ );
161
+ const level = (file: string, content: string) =>
162
+ parseLevel(
163
+ file,
164
+ snippets.has(file) ? replaceVariables(content, site.variables) : content,
165
+ site.baseDir
166
+ );
167
+
168
+ const applied: Change[] = [];
169
+ for (const [file, picks] of chosen) {
170
+ const content = await read(file);
171
+ const updated = applyEdits(content, selectEdits(picks.map(({ fix }) => fix)));
172
+ if (/\.mdx?$/.test(file) && level(file, updated) < level(file, content)) {
173
+ continue;
174
+ }
175
+ await fs.writeFile(path.join(site.baseDir, file), updated);
176
+ applied.push(...picks.map(({ change }) => change));
177
+ }
178
+ return applied;
179
+ };
180
+
181
+ // Fixes can unlock others (a self-closed <br> lets a page parse), so --fix runs a few passes
182
+ const MAX_FIX_PASSES = 5;
183
+
184
+ /**
185
+ * Loads the site and runs the selected rules. With fix, applies the fixes, rechecks only the
186
+ * files that changed for further fixes, then runs every rule once more for the final report.
187
+ */
114
188
  export const checkSite = async (
115
189
  baseDir: string,
116
190
  rules: Rule[],
117
- options: { selected?: ReadonlySet<string>; localSchema: boolean }
191
+ options: { selected?: ReadonlySet<string>; localSchema: boolean; fix: boolean }
118
192
  ) => {
193
+ const fixed: Change[] = [];
119
194
  const site = await loadSite(baseDir, options);
120
195
  const { selected } = options;
121
196
  if (selected && !site.files.some(({ file }) => selected.has(file))) {
@@ -124,5 +199,18 @@ export const checkSite = async (
124
199
  );
125
200
  }
126
201
  const diagnostics = await runChecks(site, rules);
127
- return { site, diagnostics };
202
+ if (!options.fix) return { site, diagnostics, fixed };
203
+
204
+ // Only file rules offer fixes, so passes after the first recheck just the files that changed
205
+ let pending = diagnostics;
206
+ for (let pass = 0; pass < MAX_FIX_PASSES; pass++) {
207
+ const changes = await applyFixes(site, pending);
208
+ if (changes.length === 0) break;
209
+ fixed.push(...changes);
210
+ const changed = new Set(changes.map(({ file }) => file));
211
+ pending = sortDiagnostics(await checkFiles({ ...site, selected: changed }, fileRulesOf(rules)));
212
+ }
213
+ if (fixed.length === 0) return { site, diagnostics, fixed };
214
+ const fixedSite = await loadSite(baseDir, options);
215
+ return { site: fixedSite, diagnostics: await runChecks(fixedSite, rules), fixed };
128
216
  };
@@ -99,7 +99,7 @@ const tryParseContent = (content: string): Root | undefined => {
99
99
  }
100
100
  };
101
101
 
102
- const parseFile = (file: string, content: string, baseDir: string): Parsed => {
102
+ export const parseFile = (file: string, content: string, baseDir: string): Parsed => {
103
103
  const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
104
104
  const tree = tryParseContent(content);
105
105
  if (tree) return { context: { ...context, tree }, approximate: false };