@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.
- package/__test__/validateCommand.test.ts +126 -27
- package/__test__/validateEngine.test.ts +186 -2
- package/__test__/validateRules.test.ts +64 -49
- package/bin/cli.js +13 -1
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/bin/validate/command.js +48 -10
- package/bin/validate/engine.js +75 -14
- package/bin/validate/files.js +1 -1
- package/bin/validate/output.js +13 -2
- package/bin/validate/report.js +3 -8
- package/package.json +4 -4
- package/src/cli.tsx +20 -1
- package/src/validate/command.tsx +48 -9
- package/src/validate/engine.ts +101 -13
- package/src/validate/files.ts +1 -1
- package/src/validate/output.tsx +18 -0
- package/src/validate/report.ts +9 -2
- package/src/validate/types.ts +10 -0
package/bin/validate/command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
});
|
package/bin/validate/engine.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/bin/validate/files.js
CHANGED
|
@@ -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)
|
package/bin/validate/output.js
CHANGED
|
@@ -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
|
-
(
|
|
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" }));
|
package/bin/validate/report.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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(
|
|
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'
|
package/src/validate/command.tsx
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
};
|
package/src/validate/engine.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/validate/files.ts
CHANGED
|
@@ -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 };
|