@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.
- package/__test__/validateCommand.test.ts +20 -0
- package/__test__/validateEngine.test.ts +186 -2
- package/__test__/validateRules.test.ts +64 -49
- package/bin/cli.js +6 -1
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/bin/validate/command.js +3 -2
- 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 +2 -2
- package/src/cli.tsx +9 -1
- package/src/validate/command.tsx +4 -2
- 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
|
@@ -52,7 +52,8 @@ export const validateHandler = (argv_1, packageName_1, ...args_1) => __awaiter(v
|
|
|
52
52
|
const _b = yield checkSite(baseDir, rules, {
|
|
53
53
|
selected,
|
|
54
54
|
localSchema: argv['local-schema'],
|
|
55
|
-
|
|
55
|
+
fix: argv.fix,
|
|
56
|
+
}), { site, fixed } = _b, checked = __rest(_b, ["site", "fixed"]);
|
|
56
57
|
let { diagnostics } = checked;
|
|
57
58
|
const buildRules = rules.some(({ needsBuild }) => needsBuild);
|
|
58
59
|
let build;
|
|
@@ -73,7 +74,7 @@ export const validateHandler = (argv_1, packageName_1, ...args_1) => __awaiter(v
|
|
|
73
74
|
diagnostics = yield runBuildChecks(site, rules, diagnostics);
|
|
74
75
|
build = { ran: true, passed: site.build.passed };
|
|
75
76
|
}
|
|
76
|
-
const report = toReport(diagnostics, build);
|
|
77
|
+
const report = toReport(diagnostics, build, argv.fix ? fixed : undefined);
|
|
77
78
|
clearLogs();
|
|
78
79
|
logReport(report);
|
|
79
80
|
if (report.passed && build.ran && build.passed)
|
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.1741",
|
|
4
4
|
"description": "The Mintlify CLI",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.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": "4e0cdb6a51f1b140f02f0579f9e4c32592220434"
|
|
105
105
|
}
|
package/src/cli.tsx
CHANGED
|
@@ -401,6 +401,11 @@ 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
|
+
})
|
|
404
409
|
.usage('usage: mint validate [options]')
|
|
405
410
|
.epilogue(
|
|
406
411
|
[
|
|
@@ -410,7 +415,10 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
410
415
|
].join('\n')
|
|
411
416
|
)
|
|
412
417
|
.example('mint validate', 'run every check and the build')
|
|
413
|
-
.example(
|
|
418
|
+
.example(
|
|
419
|
+
'mint validate --files "guides/**/*.mdx" --fix',
|
|
420
|
+
'check some pages and apply fixes'
|
|
421
|
+
)
|
|
414
422
|
.example(
|
|
415
423
|
'mint validate --disable all --enable content',
|
|
416
424
|
'run the content rules only; missing-file and import-error still run the build'
|
package/src/validate/command.tsx
CHANGED
|
@@ -16,6 +16,7 @@ export type ValidateArgs = {
|
|
|
16
16
|
build: boolean;
|
|
17
17
|
enable?: string[];
|
|
18
18
|
disable?: string[];
|
|
19
|
+
fix: boolean;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export const EXIT = { passed: 0, failed: 1, crashed: 2 } as const;
|
|
@@ -53,9 +54,10 @@ export const validateHandler = async (
|
|
|
53
54
|
const selected = await resolveSelection(baseDir, argv.files);
|
|
54
55
|
addLog(<SpinnerLog message="validating..." />);
|
|
55
56
|
|
|
56
|
-
const { site, ...checked } = await checkSite(baseDir, rules, {
|
|
57
|
+
const { site, fixed, ...checked } = await checkSite(baseDir, rules, {
|
|
57
58
|
selected,
|
|
58
59
|
localSchema: argv['local-schema'],
|
|
60
|
+
fix: argv.fix,
|
|
59
61
|
});
|
|
60
62
|
let { diagnostics } = checked;
|
|
61
63
|
|
|
@@ -82,7 +84,7 @@ export const validateHandler = async (
|
|
|
82
84
|
build = { ran: true, passed: site.build.passed };
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
const report = toReport(diagnostics, build);
|
|
87
|
+
const report = toReport(diagnostics, build, argv.fix ? fixed : undefined);
|
|
86
88
|
clearLogs();
|
|
87
89
|
logReport(report);
|
|
88
90
|
if (report.passed && build.ran && build.passed) await sendPublishPrompt();
|
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 };
|
package/src/validate/output.tsx
CHANGED
|
@@ -4,6 +4,11 @@ import { Box, Text } from 'ink';
|
|
|
4
4
|
import type { Report } from './report.js';
|
|
5
5
|
import type { Diagnostic } from './types.js';
|
|
6
6
|
|
|
7
|
+
const preview = (value: string) => {
|
|
8
|
+
const text = JSON.stringify(value);
|
|
9
|
+
return text.length > 60 ? `${text.slice(0, 57)}..."` : text;
|
|
10
|
+
};
|
|
11
|
+
|
|
7
12
|
const location = ({ line, column }: Pick<Diagnostic, 'line' | 'column'>) =>
|
|
8
13
|
line ? `${line}${column ? `:${column}` : ''}` : '';
|
|
9
14
|
|
|
@@ -27,6 +32,14 @@ const DiagnosticLine = ({ diagnostic }: { diagnostic: Diagnostic }) => {
|
|
|
27
32
|
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
28
33
|
|
|
29
34
|
export const logReport = (report: Report) => {
|
|
35
|
+
for (const change of report.fixed ?? []) {
|
|
36
|
+
addLog(
|
|
37
|
+
<SuccessLog
|
|
38
|
+
message={`fixed ${change.file}${change.line ? `:${change.line}` : ''} ${change.rule}: ${preview(change.before)} → ${preview(change.after)}`}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
const byFile = new Map<string, Diagnostic[]>();
|
|
31
44
|
for (const diagnostic of report.diagnostics) {
|
|
32
45
|
byFile.get(diagnostic.file)?.push(diagnostic) ?? byFile.set(diagnostic.file, [diagnostic]);
|
|
@@ -42,6 +55,11 @@ export const logReport = (report: Report) => {
|
|
|
42
55
|
);
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
const fixable = report.diagnostics.filter(({ fix }) => fix).length;
|
|
59
|
+
if (fixable > 0 && !report.fixed) {
|
|
60
|
+
addLog(<InfoLog message={`\`--fix\` would fix ${plural(fixable, 'issue')}.`} />);
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
const { build } = report;
|
|
46
64
|
if (build.ran && build.passed) addLog(<SuccessLog message="build passed" />);
|
|
47
65
|
else if (build.ran && report.passed)
|
package/src/validate/report.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Diagnostic } from './types.js';
|
|
1
|
+
import type { Change, Diagnostic } from './types.js';
|
|
2
2
|
|
|
3
3
|
export type BuildStatus =
|
|
4
4
|
| { ran: true; passed: boolean }
|
|
@@ -9,9 +9,15 @@ export type Report = {
|
|
|
9
9
|
summary: { errors: number; warnings: number; byRule: Record<string, number> };
|
|
10
10
|
diagnostics: Diagnostic[];
|
|
11
11
|
build: BuildStatus;
|
|
12
|
+
/** With --fix: the edits that were applied. */
|
|
13
|
+
fixed?: Change[];
|
|
12
14
|
};
|
|
13
15
|
|
|
14
|
-
export const toReport = (
|
|
16
|
+
export const toReport = (
|
|
17
|
+
diagnostics: Diagnostic[],
|
|
18
|
+
build: BuildStatus,
|
|
19
|
+
fixed?: Change[]
|
|
20
|
+
): Report => {
|
|
15
21
|
const byRule: Record<string, number> = {};
|
|
16
22
|
for (const { rule } of diagnostics) byRule[rule] = (byRule[rule] ?? 0) + 1;
|
|
17
23
|
const errors = diagnostics.filter(({ severity }) => severity === 'error').length;
|
|
@@ -29,5 +35,6 @@ export const toReport = (diagnostics: Diagnostic[], build: BuildStatus): Report
|
|
|
29
35
|
...(fix && { fix }),
|
|
30
36
|
})),
|
|
31
37
|
build,
|
|
38
|
+
...(fixed && { fixed }),
|
|
32
39
|
};
|
|
33
40
|
};
|
package/src/validate/types.ts
CHANGED
|
@@ -11,6 +11,16 @@ export type Edit = { start: number; end: number; text: string };
|
|
|
11
11
|
|
|
12
12
|
export type FileEdit = Edit & { file: string };
|
|
13
13
|
|
|
14
|
+
/** A fix --fix applied: where it was and the text it replaced. */
|
|
15
|
+
export type Change = {
|
|
16
|
+
rule: string;
|
|
17
|
+
file: string;
|
|
18
|
+
line?: number;
|
|
19
|
+
column?: number;
|
|
20
|
+
before: string;
|
|
21
|
+
after: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
14
24
|
export type Diagnostic = {
|
|
15
25
|
rule: string;
|
|
16
26
|
group: Group;
|